Skip to content

Conversation

@Judahmeek
Copy link
Contributor

@Judahmeek Judahmeek commented Nov 12, 2025

Since async hydration is only supported by ReactOnRails Pro now that the Callback Registry has been moved to ReactOnRails Pro only, we need to either revert back to ReactOnRails only hydrating after all subresources are loaded or return the CallbackRegistry & async support back to ReactOnRails in order to prevent component registration race conditions like this one.


This change is Reviewable

Summary by CodeRabbit

  • Refactor

    • Page lifecycle now uses a readiness-driven flow to wait for full document readiness before attaching navigation listeners, ensuring initialization occurs at the correct time.
  • Tests

    • Updated tests to use readiness-driven semantics: callbacks run immediately when the document is already ready or otherwise wait for the readiness event.
    • Removed tests and shared setup that relied on pro-mode immediate hydration.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 12, 2025

Walkthrough

Introduces an internal onPageReady(callback) wrapper that centralizes readiness handling via document.readyState and the "readystatechange" event; replaces prior initialization path to drive setupPageNavigationListeners. Tests updated to reflect readystatechange semantics. System specs removing pro/immediate-hydration contexts and two dependent tests.

Changes

Cohort / File(s) Summary
Page lifecycle: readiness wrapper
packages/react-on-rails/src/pageLifecycle.ts
Added internal onPageReady(callback) which calls the callback immediately if document.readyState === "complete", otherwise adds a readystatechange listener that re-checks readyState, invokes the callback and removes itself. Replaced earlier readiness/initialization calls with onPageReady(setupPageNavigationListeners). No exported signatures changed.
Tests: readystatechange expectations
packages/react-on-rails/tests/pageLifecycle.test.js
Updated tests to use readystatechange semantics: initialize document.readyState to "loading", assert callbacks are not immediate, verify addEventListener called with 'readystatechange', and test behavior when readyState becomes "complete". Removed duplicate/obsolete initialization tests and the duplicate "preventing duplicate initialization" block.
System specs: removed pro/immediate-hydration contexts
spec/dummy/spec/system/integration_spec.rb
Removed shared_context that configured ReactOnRails pro mode and immediate hydration and removed two spec blocks that used it ("Turbolinks across pages" and "TurboStream send react component"), eliminating tests that relied on pro/immediate-hydration setup.

Sequence Diagram(s)

sequenceDiagram
    participant Caller as onPageLoaded/onPageUnloaded
    participant OnReady as onPageReady(callback)
    participant Document as document
    participant Setup as setupPageNavigationListeners

    Caller->>OnReady: request setup
    OnReady->>Document: read `readyState`
    alt readyState == "complete"
        OnReady->>Setup: invoke callback immediately
    else readyState != "complete"
        OnReady->>Document: addEventListener("readystatechange", handler)
        Document->>OnReady: "readystatechange" fires
        OnReady->>OnReady: re-check `readyState`
        OnReady->>Setup: invoke callback
        OnReady->>Document: removeEventListener("readystatechange", handler)
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

  • Pay attention to:
    • Correct add/remove of the readystatechange listener and that the handler re-enters the wrapper.
    • Tests properly simulating document.readyState transitions and firing readystatechange.
    • Deleted system specs—verify CI expectations and no dependent tests remain.

Possibly related PRs

Suggested reviewers

  • AbanoubGhadban
  • alexeyr-ci2

Poem

🐰
I wait until the document says it's "complete" and then I hop,
If not, I listen for readystatechange and stop,
I bind once, call once, then tidy up and flee,
A tiny rabbit watcher, ready when ready — whee! 🥕

Pre-merge checks and finishing touches

❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
Title check ❓ Inconclusive The PR title describes delaying hydration until subresources are loaded, but the changes focus on replacing DOMContentLoaded with readystatechange-based logic and removing pro-feature tests without explicit subresource loading mechanisms. Clarify whether the title specifically refers to the readystatechange implementation details, or provide more context about how subresource loading is being tracked.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch judahmeek/debug-image-example

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai bot added the bug label Nov 12, 2025
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/react-on-rails/src/pageLifecycle.ts (1)

72-78: Remove duplicate initialization of page navigation listeners.

Both onPageLoaded and onPageUnloaded call onPageReady(setupPageNavigationListeners), which causes event listeners to be registered twice during initialization. Since these functions are called together in createReactOnRailsPro.ts (line 77-78) and CallbackRegistry.ts (line 63-70), setupPageNavigationListeners executes twice, adding duplicate event listeners for Turbo/Turbolinks navigation events. This causes callbacks to fire multiple times unintentionally.

Only onPageLoaded should initialize navigation listeners. Remove the call to onPageReady(setupPageNavigationListeners) from onPageUnloaded (line 85).

🧹 Nitpick comments (1)
packages/react-on-rails/src/pageLifecycle.ts (1)

61-70: Simplify the readystatechange listener pattern.

The recursive pattern works but is unnecessarily complex. The listener recursively calls onPageReady, which may register a new listener before the current one is removed. A clearer approach would check the readyState directly in the listener without recursion.

Apply this diff to simplify:

-function onPageReady(callback: () => void) {
-  if (document.readyState === 'complete') {
-    callback();
-  } else {
-    document.addEventListener('readystatechange', function onReadyStateChange() {
-      onPageReady(callback);
-      document.removeEventListener('readystatechange', onReadyStateChange);
-    });
-  }
-}
+function onPageReady(callback: () => void) {
+  if (document.readyState === 'complete') {
+    callback();
+  } else {
+    const listener = () => {
+      if (document.readyState === 'complete') {
+        document.removeEventListener('readystatechange', listener);
+        callback();
+      }
+    };
+    document.addEventListener('readystatechange', listener);
+  }
+}
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8a977b3 and ea2b866.

📒 Files selected for processing (1)
  • packages/react-on-rails/src/pageLifecycle.ts (2 hunks)
🧰 Additional context used
🧠 Learnings (2)
📓 Common learnings
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1781
File: node_package/src/ClientSideRenderer.ts:82-95
Timestamp: 2025-09-15T21:24:48.207Z
Learning: In React on Rails, the force_load feature includes both explicit `data-force-load="true"` usage and the ability to hydrate components during the page loading state (`document.readyState === 'loading'`). Both capabilities require a Pro license, so the condition `!railsContext.rorPro && (isComponentForceLoaded || document.readyState === 'loading')` correctly gates both scenarios.
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1875
File: lib/react_on_rails/utils.rb:112-124
Timestamp: 2025-10-23T17:22:01.074Z
Learning: In React on Rails, when Pro is installed but not licensed, the intended behavior is to raise an error on boot. The `react_on_rails_pro?` method validates licenses and should raise errors early (including during path resolution in methods like `server_bundle?`) to enforce licensing requirements rather than failing later with obscure errors.
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1644
File: node_package/src/clientStartup.ts:18-21
Timestamp: 2025-02-13T16:50:47.848Z
Learning: In the react_on_rails module, the `reactOnRailsPageUnloaded` function in clientStartup.ts is intentionally kept private as it's only used internally as a callback for `onPageUnloaded`.
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1644
File: node_package/src/turbolinksUtils.ts:34-36
Timestamp: 2025-02-13T16:50:26.861Z
Learning: In React on Rails, when checking for Turbolinks version 5 using `turbolinksVersion5()`, always ensure `Turbolinks` exists first by checking `turbolinksInstalled()` to prevent TypeError when accessing properties.
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1644
File: lib/react_on_rails/helper.rb:190-197
Timestamp: 2025-02-18T13:08:01.477Z
Learning: RSC support validation is handled in deeper level calls of the React on Rails Pro codebase, so it doesn't need to be validated again in the `rsc_payload_react_component` helper method.
📚 Learning: 2025-02-13T16:50:47.848Z
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1644
File: node_package/src/clientStartup.ts:18-21
Timestamp: 2025-02-13T16:50:47.848Z
Learning: In the react_on_rails module, the `reactOnRailsPageUnloaded` function in clientStartup.ts is intentionally kept private as it's only used internally as a callback for `onPageUnloaded`.

Applied to files:

  • packages/react-on-rails/src/pageLifecycle.ts
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: build-dummy-app-webpack-test-bundles (3.2, 20, minimum)
  • GitHub Check: build
  • GitHub Check: claude-review
🔇 Additional comments (1)
packages/react-on-rails/src/pageLifecycle.ts (1)

61-70: Approach aligns with PR objectives.

The onPageReady function correctly waits for document.readyState === 'complete', ensuring all subresources (images, stylesheets, scripts) are fully loaded before initializing page navigation listeners and running callbacks. This aligns with the PR objective to prevent race conditions by deferring hydration until the page is fully ready.

Note: The duplicate initialization issue flagged in the other comment must be resolved to fully achieve the PR's goal.

@claude
Copy link

claude bot commented Nov 12, 2025

Code Review Feedback

Summary

This PR addresses a critical race condition where components may attempt to hydrate before all subresources (including component registrations) are loaded. The change moves from waiting for DOMContentLoaded to waiting for document.readyState === 'complete', which ensures all subresources are loaded before hydration begins.

✅ Positives

  1. Fixes Critical Race Condition: The change correctly addresses the issue where component registration may not be complete when hydration starts, which could cause CI failures.

  2. Correct Event Strategy: Using readystatechange with document.readyState === 'complete' is the right approach for waiting for all subresources (scripts, stylesheets, images) to load, not just DOM parsing.

  3. Cleaner Architecture: The refactor simplifies the code by:

    • Removing the global isPageLifecycleInitialized flag
    • Creating a more focused onPageReady function
    • Eliminating the initializePageEventListeners wrapper

⚠️ Concerns & Issues

1. Potential Memory Leak (Critical)

The current implementation has a memory leak in the recursive pattern:

function onPageReady(callback: () => void) {
  if (document.readyState === 'complete') {
    callback();
  } else {
    document.addEventListener('readystatechange', function onReadyStateChange() {
      onPageReady(callback);  // ⚠️ Recursive call before removeEventListener
      document.removeEventListener('readystatechange', onReadyStateChange);
    });
  }
}

Problem: If readyState transitions from 'loading''interactive''complete', the listener will fire twice:

  • First fire: readyState === 'interactive', recursively adds another listener
  • Second fire: readyState === 'complete', executes callback
  • The first listener's removeEventListener runs AFTER the recursive call, leaving potential cleanup issues

Recommended Fix:

function onPageReady(callback: () => void) {
  if (document.readyState === 'complete') {
    callback();
  } else {
    const onReadyStateChange = () => {
      if (document.readyState === 'complete') {
        document.removeEventListener('readystatechange', onReadyStateChange);
        callback();
      }
    };
    document.addEventListener('readystatechange', onReadyStateChange);
  }
}

2. Multiple Calls to setupPageNavigationListeners

Since onPageReady is called from both onPageLoaded and onPageUnloaded, and these functions can be called multiple times, setupPageNavigationListeners will be invoked multiple times without protection.

Problem: This could lead to:

  • Multiple event listeners being registered for the same events
  • runPageLoadedCallbacks() being called multiple times immediately

Recommended Fix: Restore the initialization guard:

let isPageLifecycleInitialized = false;

function onPageReady(callback: () => void) {
  if (isPageLifecycleInitialized) {
    return;
  }
  
  if (document.readyState === 'complete') {
    isPageLifecycleInitialized = true;
    callback();
  } else {
    const onReadyStateChange = () => {
      if (document.readyState === 'complete') {
        document.removeEventListener('readystatechange', onReadyStateChange);
        isPageLifecycleInitialized = true;
        callback();
      }
    };
    document.addEventListener('readystatechange', onReadyStateChange);
  }
}

3. Missing Window Check

The original code had if (typeof window === 'undefined') return; to handle SSR scenarios. This check is now missing.

Impact: While the calling code in clientStartup.ts checks for document, it's better defensive programming to keep the check in onPageReady as well, especially since it's exported and could be called from other contexts.

Recommended Fix: Add the check at the beginning of onPageReady.

4. Performance Impact (Minor)

Waiting for document.readyState === 'complete' means waiting for ALL resources (images, stylesheets, etc.) to load, which could delay hydration compared to DOMContentLoaded.

Question: Have you measured the performance impact? For pages with large images or slow-loading resources, this could noticeably delay interactivity.

Alternative Consideration: Since the core issue is component registration race conditions, have you considered:

  • Ensuring component bundles are loaded with proper defer or module script ordering?
  • Using a more targeted approach that waits only for script resources, not all resources?

📝 Additional Suggestions

  1. Add Tests: This critical lifecycle code has no test coverage. Consider adding unit tests for:

    • Various readyState transitions
    • Multiple calls to onPageLoaded/onPageUnloaded
    • Navigation library detection timing
  2. Add JSDoc Comments: Document the behavior change:

    /**
     * Waits for all page resources to load before executing callback.
     * Uses document.readyState === 'complete' to ensure all subresources
     * (scripts, stylesheets, images) are loaded, preventing race conditions
     * with component registration.
     */
  3. Consider Feature Detection: If the performance impact is too high, consider a hybrid approach that only waits for complete when async component loading is detected.

🎯 Verdict

Concept: ✅ Correct approach to fixing the race condition

Implementation: ⚠️ Needs fixes for memory leak and duplicate execution issues

Priority: Fix the critical issues (#1 and #2) before merging, as they could cause production bugs that are harder to debug than the original race condition.


Review based on repo's CLAUDE.md guidelines. TypeScript/React on Rails best practices applied.

@claude
Copy link

claude bot commented Nov 12, 2025

Code Review: Only initiate hydration after all subresources loaded

Thank you for this PR @Judahmeek! I've reviewed the changes and here's my feedback:

Summary

This PR changes the page lifecycle initialization to wait for document.readyState === 'complete' instead of DOMContentLoaded, ensuring all subresources (images, stylesheets, scripts) are loaded before hydration begins. This prevents component registration race conditions.


✅ Strengths

  1. Addresses Real Issue: The PR fixes a legitimate race condition where components may try to hydrate before their registration code has loaded
  2. Clean Implementation: The onPageReady function is well-structured with proper cleanup of event listeners
  3. Test Coverage: Tests have been updated to match the new behavior
  4. Backward Compatibility: Maintains the same public API

🔍 Issues & Concerns

1. Critical Logic Bug in onPageReady

The recursive call pattern creates an infinite loop risk:

function onPageReady(callback: () => void) {
  if (isPageLifecycleInitialized) {
    return;  // ❌ Returns without calling callback!
  }
  isPageLifecycleInitialized = true;
  
  if (document.readyState === 'complete') {
    callback();
  } else {
    document.addEventListener('readystatechange', function onReadyStateChange() {
      onPageReady(callback);  // ❌ Calls itself recursively
      document.removeEventListener('readystatechange', onReadyStateChange);
    });
  }
}

Problems:

  • Line 74: onPageReady(callback) calls itself recursively, but isPageLifecycleInitialized is already true
  • This means the callback will never execute on subsequent calls
  • The guard at line 65-67 prevents the callback from running

Expected behavior: The event listener should check document.readyState === 'complete' directly and call the callback, not recursively call onPageReady.

Suggested fix:

function onPageReady(callback: () => void) {
  if (typeof window === 'undefined') return;

  if (isPageLifecycleInitialized) {
    return;
  }
  isPageLifecycleInitialized = true;

  if (document.readyState === 'complete') {
    callback();
  } else {
    document.addEventListener('readystatechange', function onReadyStateChange() {
      if (document.readyState === 'complete') {
        callback();
        document.removeEventListener('readystatechange', onReadyStateChange);
      }
    });
  }
}

2. Test Naming Inconsistency

Line 84: Test name says "interactive" but sets readyState to "loading":

it('should wait for readystatechange when document.readyState is "interactive"', () => {
  setReadyState('loading');  // ❌ Mismatch!

This should either test the "interactive" state or rename to match "loading".

3. Missing Test Coverage

The tests don't verify that the callback actually executes when readystatechange fires. Current tests only verify that:

  • The event listener is added
  • The callback doesn't run immediately

But they don't test:

  • Does the callback run when readyState becomes 'complete'?
  • Does the event listener properly clean up?

4. Performance Consideration

Waiting for readyState === 'complete' delays hydration until all resources load (images, fonts, etc.), which could be significantly slower than DOMContentLoaded.

Questions:

  • Have you measured the performance impact on real applications?
  • Could we use a more targeted approach (e.g., wait for specific script bundles)?
  • What about progressive hydration for critical components?

5. Broader Architecture Question

The PR description mentions this is needed because async hydration moved to Pro. Some questions:

  • Is delaying all hydration the right solution, or should we focus on making component registration more robust?
  • Could we use a registry promise/ready state instead of relying on page load events?
  • Would a manifest of required bundles be more explicit than implicit timing?

🧪 Testing Recommendations

  1. Add integration test: Verify the actual callback execution when readyState changes
  2. Add concurrent call test: Test what happens when onPageLoaded is called multiple times rapidly
  3. Add slow resource test: Verify behavior when resources take a long time to load

📋 Action Items

Must fix before merge:

  1. ✅ Fix the recursive call bug in onPageReady
  2. ✅ Fix test name on line 84 or change the readyState to match
  3. ✅ Add test coverage for callback execution on readystatechange

Should consider:

  1. ⚠️ Measure and document performance impact
  2. ⚠️ Consider alternative solutions (registry-based readiness?)
  3. ⚠️ Add migration guide if this affects hydration timing significantly

🎯 Verdict

Status: Needs Changes ⚠️

The approach is sound, but the implementation has a critical bug that will prevent callbacks from executing. Once fixed and tested, this should address the race condition effectively.

Please let me know if you'd like me to clarify any of these points or help with the fixes!


Review generated using repository conventions from CLAUDE.md

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/react-on-rails/tests/pageLifecycle.test.js (1)

84-108: Tests don't verify that callbacks execute after readystatechange fires.

The tests at lines 84-108 verify that a readystatechange listener is added when readyState is not 'complete', but they never simulate the event firing to confirm the callback is eventually executed. This is why the critical bug in onPageReady (where the recursive call hits an early return) wasn't caught by the test suite.

Add test coverage to simulate the readystatechange event and verify callback execution:

it('should execute callback when readystatechange fires', () => {
  setReadyState('loading');
  const { onPageLoaded } = importPageLifecycle();
  const callback = jest.fn();

  onPageLoaded(callback);

  // Callback should not be called yet
  expect(callback).not.toHaveBeenCalled();

  // Simulate document becoming ready
  setReadyState('complete');
  const readyStateChangeHandler = addEventListenerSpy.mock.calls.find(
    call => call[0] === 'readystatechange'
  )[1];
  readyStateChangeHandler();

  // Now callback should have been executed
  expect(callback).toHaveBeenCalledTimes(1);
});
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ea2b866 and 90971d0.

📒 Files selected for processing (2)
  • packages/react-on-rails/src/pageLifecycle.ts (2 hunks)
  • packages/react-on-rails/tests/pageLifecycle.test.js (2 hunks)
🧰 Additional context used
🧠 Learnings (3)
📓 Common learnings
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1781
File: node_package/src/ClientSideRenderer.ts:82-95
Timestamp: 2025-09-15T21:24:48.207Z
Learning: In React on Rails, the force_load feature includes both explicit `data-force-load="true"` usage and the ability to hydrate components during the page loading state (`document.readyState === 'loading'`). Both capabilities require a Pro license, so the condition `!railsContext.rorPro && (isComponentForceLoaded || document.readyState === 'loading')` correctly gates both scenarios.
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1875
File: lib/react_on_rails/utils.rb:112-124
Timestamp: 2025-10-23T17:22:01.074Z
Learning: In React on Rails, when Pro is installed but not licensed, the intended behavior is to raise an error on boot. The `react_on_rails_pro?` method validates licenses and should raise errors early (including during path resolution in methods like `server_bundle?`) to enforce licensing requirements rather than failing later with obscure errors.
📚 Learning: 2025-02-13T16:50:47.848Z
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1644
File: node_package/src/clientStartup.ts:18-21
Timestamp: 2025-02-13T16:50:47.848Z
Learning: In the react_on_rails module, the `reactOnRailsPageUnloaded` function in clientStartup.ts is intentionally kept private as it's only used internally as a callback for `onPageUnloaded`.

Applied to files:

  • packages/react-on-rails/tests/pageLifecycle.test.js
  • packages/react-on-rails/src/pageLifecycle.ts
📚 Learning: 2025-02-13T16:50:26.861Z
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1644
File: node_package/src/turbolinksUtils.ts:34-36
Timestamp: 2025-02-13T16:50:26.861Z
Learning: In React on Rails, when checking for Turbolinks version 5 using `turbolinksVersion5()`, always ensure `Turbolinks` exists first by checking `turbolinksInstalled()` to prevent TypeError when accessing properties.

Applied to files:

  • packages/react-on-rails/src/pageLifecycle.ts
🧬 Code graph analysis (1)
packages/react-on-rails/tests/pageLifecycle.test.js (1)
packages/react-on-rails/src/pageLifecycle.ts (1)
  • onPageLoaded (80-86)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (4)
  • GitHub Check: build
  • GitHub Check: build-dummy-app-webpack-test-bundles (3.2, 20, minimum)
  • GitHub Check: build-dummy-app-webpack-test-bundles (3.4, 22, latest)
  • GitHub Check: claude-review

@claude
Copy link

claude bot commented Nov 12, 2025

Code Review for PR #1988

Summary

This PR changes the page lifecycle initialization to wait for document.readyState === 'complete' instead of \!== 'loading', ensuring hydration only begins after all subresources (images, stylesheets, scripts) are loaded. This addresses component registration race conditions that emerged after the CallbackRegistry was moved to Pro-only in PR #1841.


✅ Positive Aspects

  1. Addresses Real Issue: The race condition fix is legitimate - ensuring all subresources load before hydration prevents component registration timing issues.

  2. Test Coverage Updated: Tests properly updated to reflect new behavior with readystatechange events.

  3. Clean Event Listener Management: Proper cleanup with removeEventListener in the onReadyStateChange handler prevents memory leaks.

  4. Maintains SSR Safety: Correctly preserves typeof window === 'undefined' check for server-side rendering.


🔴 Critical Issues

1. Breaking Behavioral Change

Location: pageLifecycle.ts:70-71

// BEFORE: Runs on 'interactive' OR 'complete'
if (document.readyState \!== 'loading') {

// AFTER: Only runs on 'complete'  
if (document.readyState === 'complete') {

Problem: This significantly delays hydration. The interactive state means DOM is ready but subresources (images, fonts, CSS) may still be loading. For most React apps, starting hydration at interactive is perfectly safe and provides better UX.

Impact: Users will see a longer delay before React components become interactive, especially on slow networks where images/fonts take time to load.

Recommendation: Consider a more nuanced approach:

  • Default to 'interactive' for better UX
  • Add a configuration option for apps that need 'complete' due to specific timing requirements
  • Or document this as a breaking change with clear rationale

2. Incorrect Event Listener Choice

Location: pageLifecycle.ts:73

document.addEventListener('readystatechange', function onReadyStateChange() {
  onPageReady(callback);
  document.removeEventListener('readystatechange', onReadyStateChange);
});

Problem: Using readystatechange with recursive calls to onPageReady() is unnecessarily complex and fragile.

Issues:

  • readystatechange fires multiple times (loading → interactive → complete)
  • Recursive call to onPageReady() on each state change is confusing
  • The isPageLifecycleInitialized guard prevents re-initialization, but the pattern is error-prone

Better approach (if waiting for 'complete' is truly necessary):

if (document.readyState === 'complete') {
  callback();
} else {
  document.addEventListener('load', callback, { once: true });
}

Or using readystatechange with explicit state check:

document.addEventListener('readystatechange', function onReadyStateChange() {
  if (document.readyState === 'complete') {
    callback();
    document.removeEventListener('readystatechange', onReadyStateChange);
  }
});

3. Potential Infinite Recursion Risk

Location: pageLifecycle.ts:73-76

While the isPageLifecycleInitialized flag prevents actual infinite recursion, calling onPageReady(callback) from within the readystatechange listener is confusing and makes the control flow hard to follow.

Current flow:

  1. onPageReady() adds readystatechange listener
  2. State changes from loading → interactive
  3. Listener calls onPageReady() again (recursive call)
  4. isPageLifecycleInitialized returns early
  5. State changes from interactive → complete
  6. Listener calls onPageReady() again
  7. Callback finally executes

This is unnecessarily convoluted.


⚠️ Moderate Issues

4. Test Name Mismatch

Location: pageLifecycle.test.js:84

it('should wait for readystatechange when document.readyState is "interactive"', () => {
  setReadyState('loading'); // ← Test says 'interactive' but sets 'loading'

Issue: Test description says "interactive" but actually tests "loading" state. This is confusing and indicates the test was updated but the description wasn't properly adjusted.

Recommendation: Either:

  • Update test name to: 'should wait for readystatechange when document.readyState is "loading"'
  • Add a separate test for 'interactive' state behavior

5. Missing Test Coverage

The PR doesn't test the actual readystatechange event firing behavior. Current tests only verify that the listener is added, not that the callback executes when the state actually changes.

Recommended additional test:

it('should execute callback when readyState changes to complete', () => {
  setReadyState('loading');
  const callback = jest.fn();
  const { onPageLoaded } = importPageLifecycle();
  
  onPageLoaded(callback);
  expect(callback).not.toHaveBeenCalled();
  
  // Simulate state change
  setReadyState('complete');
  // Manually trigger the event since jsdom doesn't auto-fire it
  const event = new Event('readystatechange');
  document.dispatchEvent(event);
  
  expect(callback).toHaveBeenCalledTimes(1);
});

📊 Performance Considerations

Potential Regression: Waiting for 'complete' instead of 'interactive' can add hundreds of milliseconds to Time-to-Interactive (TTI), especially on:

  • Slow networks
  • Pages with many images
  • Pages with web fonts
  • Pages with large CSS files

Recommendation: Add performance monitoring or at least document this trade-off clearly.


🔒 Security Concerns

No security issues identified. The change doesn't introduce XSS, injection, or other security vulnerabilities.


💡 Alternative Solutions

Instead of this breaking change, consider:

  1. Restore CallbackRegistry to core (as mentioned in PR description) - This would allow async component registration without timing changes

  2. Add configuration option:

    ReactOnRails.configure({
      hydrationTiming: 'interactive' | 'complete' // Default: 'interactive'
    });
  3. Component-level control: Let components opt-in to delayed hydration if needed

  4. Better error handling: Add warnings when components aren't registered yet, rather than delaying all hydration


📋 Recommendations Summary

Before merging:

  1. Fix recursive onPageReady() call - Use direct state check in event listener
  2. Fix test name mismatch - Update test description for 'interactive' test
  3. Add proper event simulation test - Verify actual state change behavior
  4. ⚠️ Document breaking change - Clearly explain performance impact in CHANGELOG
  5. ⚠️ Consider configuration option - Allow users to choose timing strategy
  6. ⚠️ Evaluate alternatives - Is this the right fix, or should CallbackRegistry return to core?

Code Quality: 6/10 - Functional but suboptimal implementation
Test Coverage: 7/10 - Tests updated but missing event firing validation
Performance Impact: Negative - Will delay interactivity noticeably
Security: ✅ No concerns


🎯 Suggested Code Fix

function onPageReady(callback: () => void) {
  if (typeof window === 'undefined') return;

  if (isPageLifecycleInitialized) {
    return;
  }
  isPageLifecycleInitialized = true;

  if (document.readyState === 'complete') {
    callback();
  } else {
    // Use 'load' event which fires once when readyState becomes 'complete'
    window.addEventListener('load', callback, { once: true });
  }
}

This is clearer, more efficient, and avoids the recursive pattern.


Would you like me to elaborate on any of these points or provide additional code examples?

@Judahmeek Judahmeek force-pushed the judahmeek/debug-image-example branch from 90971d0 to c63c292 Compare November 12, 2025 03:08
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/react-on-rails/tests/pageLifecycle.test.js (1)

49-49: Add spy for window.addEventListener to catch the correct event target.

After fixing the production code to use window.addEventListener('load', ...), the test spy on line 49 won't capture it since it only spies on document.addEventListener.

Apply this diff to spy on both targets:

-    addEventListenerSpy = jest.spyOn(document, 'addEventListener').mockImplementation(() => {});
-    removeEventListenerSpy = jest.spyOn(document, 'removeEventListener').mockImplementation(() => {});
+    const documentAddEventListenerSpy = jest.spyOn(document, 'addEventListener').mockImplementation(() => {});
+    const documentRemoveEventListenerSpy = jest.spyOn(document, 'removeEventListener').mockImplementation(() => {});
+    const windowAddEventListenerSpy = jest.spyOn(window, 'addEventListener').mockImplementation(() => {});
+    const windowRemoveEventListenerSpy = jest.spyOn(window, 'removeEventListener').mockImplementation(() => {});
+    
+    addEventListenerSpy = {
+      document: documentAddEventListenerSpy,
+      window: windowAddEventListenerSpy,
+    };
+    removeEventListenerSpy = {
+      document: documentRemoveEventListenerSpy,
+      window: windowRemoveEventListenerSpy,
+    };

Then update line 67-68 to restore all spies, and update assertions throughout the test file to check the appropriate target (e.g., addEventListenerSpy.window for 'load', addEventListenerSpy.document for turbo/turbolinks events).

♻️ Duplicate comments (1)
packages/react-on-rails/tests/pageLifecycle.test.js (1)

84-85: Test description doesn't match the readyState being tested.

The description says "interactive" but line 85 sets readyState to "loading". Since line 97 already tests the "loading" state, update line 85 to test "interactive" to ensure both non-complete states are covered.

Apply this diff:

   it('should wait for readystatechange when document.readyState is "interactive"', () => {
-    setReadyState('loading');
+    setReadyState('interactive');
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 90971d0 and c63c292.

📒 Files selected for processing (2)
  • packages/react-on-rails/src/pageLifecycle.ts (1 hunks)
  • packages/react-on-rails/tests/pageLifecycle.test.js (2 hunks)
🧰 Additional context used
🧠 Learnings (3)
📓 Common learnings
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1781
File: node_package/src/ClientSideRenderer.ts:82-95
Timestamp: 2025-09-15T21:24:48.207Z
Learning: In React on Rails, the force_load feature includes both explicit `data-force-load="true"` usage and the ability to hydrate components during the page loading state (`document.readyState === 'loading'`). Both capabilities require a Pro license, so the condition `!railsContext.rorPro && (isComponentForceLoaded || document.readyState === 'loading')` correctly gates both scenarios.
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1875
File: lib/react_on_rails/utils.rb:112-124
Timestamp: 2025-10-23T17:22:01.074Z
Learning: In React on Rails, when Pro is installed but not licensed, the intended behavior is to raise an error on boot. The `react_on_rails_pro?` method validates licenses and should raise errors early (including during path resolution in methods like `server_bundle?`) to enforce licensing requirements rather than failing later with obscure errors.
📚 Learning: 2025-02-13T16:50:47.848Z
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1644
File: node_package/src/clientStartup.ts:18-21
Timestamp: 2025-02-13T16:50:47.848Z
Learning: In the react_on_rails module, the `reactOnRailsPageUnloaded` function in clientStartup.ts is intentionally kept private as it's only used internally as a callback for `onPageUnloaded`.

Applied to files:

  • packages/react-on-rails/tests/pageLifecycle.test.js
  • packages/react-on-rails/src/pageLifecycle.ts
📚 Learning: 2025-04-26T21:55:55.874Z
Learnt from: alexeyr-ci2
Repo: shakacode/react_on_rails PR: 1732
File: spec/dummy/client/app-react16/startup/ReduxSharedStoreApp.client.jsx:40-44
Timestamp: 2025-04-26T21:55:55.874Z
Learning: In the react_on_rails project, files under `app-react16` directories are copied/moved to corresponding `/app` directories during the conversion process (removing the `-react16` suffix), which affects their relative import paths at runtime.

Applied to files:

  • packages/react-on-rails/tests/pageLifecycle.test.js
🧬 Code graph analysis (1)
packages/react-on-rails/tests/pageLifecycle.test.js (1)
packages/react-on-rails/src/pageLifecycle.ts (1)
  • onPageLoaded (77-83)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: build
  • GitHub Check: build-dummy-app-webpack-test-bundles (3.2, 20, minimum)

expect(callback).toHaveBeenCalledTimes(1);
// Should not add DOMContentLoaded listener since readyState is not 'loading'
expect(addEventListenerSpy).not.toHaveBeenCalledWith('DOMContentLoaded', expect.any(Function));
expect(addEventListenerSpy).not.toHaveBeenCalledWith('readystatechange', expect.any(Function));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Update outdated terminology to reflect 'load' event usage.

The test descriptions and comments reference "readystatechange" but the implementation now uses the 'load' event. Update the terminology for clarity:

  • Line 81: "readystatechange" → "load event"
  • Line 84: "wait for readystatechange" → "wait for load event"
  • Lines 91-94: The comments and expectations are about the 'load' event, not readystatechange
  • Line 97: "wait for readystatechange" → "wait for load event"
  • Lines 106-107: Similar updates

After fixing the production bug to use window.addEventListener, also update line 94 and 107 expectations:

-    expect(addEventListenerSpy).toHaveBeenCalledWith('load', expect.any(Function));
+    expect(addEventListenerSpy.window).toHaveBeenCalledWith('load', expect.any(Function));

Also applies to: 84-84, 91-94, 97-97, 106-107

@claude
Copy link

claude bot commented Nov 12, 2025

Code Review

Summary

This PR addresses a critical race condition in component hydration by ensuring that page lifecycle initialization only occurs after all subresources (images, stylesheets, scripts) are fully loaded. The change moves from DOMContentLoaded to readystatechange with a complete check, preventing premature component registration that can cause hydration failures.


✅ Strengths

1. Solves a Real Problem

  • Addresses the component registration race condition documented in the CI failure
  • Restores behavior from PR #1540 that was inadvertently lost when CallbackRegistry moved to Pro in PR #1841
  • Ensures React components don't attempt to hydrate before their bundles are fully loaded

2. Clean Implementation

  • The refactoring from initializePageEventListeners to onPageReady improves code clarity
  • Proper cleanup with removeEventListener prevents memory leaks
  • Self-documenting function name (onPageReady) better expresses intent

3. Test Coverage

  • Tests updated to reflect new behavior
  • Covers edge cases: complete, loading, and interactive states
  • Tests verify listener cleanup and prevent duplicate initialization

⚠️ Issues & Concerns

1. Critical Bug: Infinite Recursion Risk

Location: pageLifecycle.ts:73-76

document.addEventListener('readystatechange', function onReadyStateChange() {
  onPageReady(callback);  // ⚠️ This recursively calls onPageReady
  document.removeEventListener('readystatechange', onReadyStateChange);
});

Problem: The onPageReady function checks isPageLifecycleInitialized and returns early if true. However, on the first readystatechange event that fires when state is NOT complete, this creates a loop:

  1. readystatechange fires (state = loadinginteractive)
  2. Calls onPageReady(callback)
  3. isPageLifecycleInitialized is already true, so returns early
  4. Callback never executes
  5. Listener is removed
  6. When state reaches complete, nothing happens

Solution: Move the guard check or explicitly check for complete state:

document.addEventListener('readystatechange', function onReadyStateChange() {
  if (document.readyState === 'complete') {
    callback();
    document.removeEventListener('readystatechange', onReadyStateChange);
  }
});

2. Test Naming Inconsistency

Location: pageLifecycle.test.js:84

The test is named:

it('should wait for readystatechange when document.readyState is "interactive"', () => {
  setReadyState('loading');  // ⚠️ But sets state to 'loading', not 'interactive'

This is misleading. Either rename the test or create a separate test for interactive state.

3. Missing Test Case: interactive State

The original code checked !== 'loading' which would trigger immediately for interactive. The new code only triggers for complete. This is likely intentional (to wait for all subresources), but there's no test verifying that interactive state correctly waits for complete.

Suggested test:

it('should wait for complete even when document.readyState is "interactive"', () => {
  setReadyState('interactive');
  const callback = jest.fn();
  const { onPageLoaded } = importPageLifecycle();

  onPageLoaded(callback);

  expect(callback).not.toHaveBeenCalled();
  expect(addEventListenerSpy).toHaveBeenCalledWith('readystatechange', expect.any(Function));
});

4. Potential Performance Impact

Waiting for complete means waiting for:

  • All images
  • All stylesheets
  • All subframes
  • All async scripts

For pages with large images or slow CDNs, this could noticeably delay hydration. Consider:

  • Adding documentation about this tradeoff
  • Providing a configuration option for users who want faster (but potentially racy) hydration
  • Using DOMContentLoaded + a timeout as fallback

🔍 Security Concerns

None identified. The changes don't introduce XSS vectors, use proper event cleanup, and don't modify user-supplied data.


🎯 Recommendations

High Priority

  1. Fix the recursion/guard logic in onPageReady (see Issue TODO for first version #1 above)
  2. Add test for interactive state behavior
  3. Fix test naming to match actual behavior

Medium Priority

  1. Document the performance tradeoff in code comments and potentially in upgrade notes
  2. Consider adding a timeout fallback to prevent indefinite waits on broken pages
  3. Add integration test that verifies the fix for the original CI failure

Low Priority

  1. Consider extracting the magic string 'complete' to a constant
  2. Add JSDoc comments explaining why complete is required vs DOMContentLoaded

📊 Overall Assessment

Recommendation: ⚠️ Request Changes

While this PR addresses a real and important bug, the recursive onPageReady call pattern has a critical flaw that needs to be fixed before merging. The test coverage is good but has a naming issue and a missing edge case.

Once the recursion issue is addressed and tests are cleaned up, this will be a solid fix that improves reliability at the cost of slightly slower hydration (which is an acceptable tradeoff for correctness).


🔧 Suggested Fix

function onPageReady(callback: () => void) {
  if (typeof window === 'undefined') return;

  if (isPageLifecycleInitialized) {
    return;
  }
  isPageLifecycleInitialized = true;

  if (document.readyState === 'complete') {
    callback();
  } else {
    const handleReadyStateChange = () => {
      if (document.readyState === 'complete') {
        callback();
        document.removeEventListener('readystatechange', handleReadyStateChange);
      }
    };
    document.addEventListener('readystatechange', handleReadyStateChange);
  }
}

This version:

  • Avoids recursion
  • Explicitly checks for complete state in the handler
  • Only removes listener when actually complete
  • More clearly expresses the intent

Let me know if you'd like me to clarify any of these points or if you have questions about the suggested changes!

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (2)
packages/react-on-rails/src/pageLifecycle.ts (1)

62-78: CRITICAL: Callback never executes when document is not ready.

The recursive call to onPageReady(callback) at line 74 will always hit the early return at lines 65-67 because isPageLifecycleInitialized was already set to true at line 68. This means callback() will never execute when the page is still loading, breaking hydration entirely.

Apply this diff to fix the logic:

 function onPageReady(callback: () => void) {
   if (typeof window === 'undefined') return;
 
   if (isPageLifecycleInitialized) {
     return;
   }
   isPageLifecycleInitialized = true;
 
   if (document.readyState === 'complete') {
     callback();
   } else {
-    document.addEventListener('readystatechange', function onReadyStateChange() {
-      onPageReady(callback);
-      document.removeEventListener('readystatechange', onReadyStateChange);
-    });
+    document.addEventListener('readystatechange', function onReadyStateChange() {
+      if (document.readyState === 'complete') {
+        document.removeEventListener('readystatechange', onReadyStateChange);
+        callback();
+      }
+    });
   }
 }
packages/react-on-rails/tests/pageLifecycle.test.js (1)

84-95: Test description doesn't match the readyState being tested.

The test description says "interactive" but line 85 sets readyState to "loading". Update the description to match the actual test behavior.

Apply this diff to fix the description:

-  it('should wait for readystatechange when document.readyState is "interactive"', () => {
+  it('should wait for readystatechange when document.readyState is "loading"', () => {
     setReadyState('loading');
🧹 Nitpick comments (1)
packages/react-on-rails/tests/pageLifecycle.test.js (1)

97-108: Consider testing the complete readystatechange flow.

This test verifies that the listener is registered but doesn't test that the callback actually executes when readystatechange fires and readyState becomes 'complete'. Consider adding a test that simulates the state change to ensure end-to-end behavior.

Example test to add:

it('should execute callback when readystatechange fires with complete state', () => {
  setReadyState('loading');
  const { onPageLoaded } = importPageLifecycle();
  const callback = jest.fn();

  onPageLoaded(callback);
  expect(callback).not.toHaveBeenCalled();

  // Simulate readystatechange event
  setReadyState('complete');
  const listener = addEventListenerSpy.mock.calls.find(
    call => call[0] === 'readystatechange'
  )?.[1];
  listener?.();

  expect(callback).toHaveBeenCalledTimes(1);
});
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c63c292 and 497c5d1.

📒 Files selected for processing (2)
  • packages/react-on-rails/src/pageLifecycle.ts (2 hunks)
  • packages/react-on-rails/tests/pageLifecycle.test.js (2 hunks)
🧰 Additional context used
🧠 Learnings (4)
📓 Common learnings
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1781
File: node_package/src/ClientSideRenderer.ts:82-95
Timestamp: 2025-09-15T21:24:48.207Z
Learning: In React on Rails, the force_load feature includes both explicit `data-force-load="true"` usage and the ability to hydrate components during the page loading state (`document.readyState === 'loading'`). Both capabilities require a Pro license, so the condition `!railsContext.rorPro && (isComponentForceLoaded || document.readyState === 'loading')` correctly gates both scenarios.
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1875
File: lib/react_on_rails/utils.rb:112-124
Timestamp: 2025-10-23T17:22:01.074Z
Learning: In React on Rails, when Pro is installed but not licensed, the intended behavior is to raise an error on boot. The `react_on_rails_pro?` method validates licenses and should raise errors early (including during path resolution in methods like `server_bundle?`) to enforce licensing requirements rather than failing later with obscure errors.
📚 Learning: 2025-02-13T16:50:47.848Z
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1644
File: node_package/src/clientStartup.ts:18-21
Timestamp: 2025-02-13T16:50:47.848Z
Learning: In the react_on_rails module, the `reactOnRailsPageUnloaded` function in clientStartup.ts is intentionally kept private as it's only used internally as a callback for `onPageUnloaded`.

Applied to files:

  • packages/react-on-rails/src/pageLifecycle.ts
📚 Learning: 2025-09-15T21:24:48.207Z
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1781
File: node_package/src/ClientSideRenderer.ts:82-95
Timestamp: 2025-09-15T21:24:48.207Z
Learning: In React on Rails, the force_load feature includes both explicit `data-force-load="true"` usage and the ability to hydrate components during the page loading state (`document.readyState === 'loading'`). Both capabilities require a Pro license, so the condition `!railsContext.rorPro && (isComponentForceLoaded || document.readyState === 'loading')` correctly gates both scenarios.

Applied to files:

  • packages/react-on-rails/src/pageLifecycle.ts
📚 Learning: 2025-02-13T16:50:26.861Z
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1644
File: node_package/src/turbolinksUtils.ts:34-36
Timestamp: 2025-02-13T16:50:26.861Z
Learning: In React on Rails, when checking for Turbolinks version 5 using `turbolinksVersion5()`, always ensure `Turbolinks` exists first by checking `turbolinksInstalled()` to prevent TypeError when accessing properties.

Applied to files:

  • packages/react-on-rails/src/pageLifecycle.ts
🧬 Code graph analysis (1)
packages/react-on-rails/tests/pageLifecycle.test.js (1)
packages/react-on-rails/src/pageLifecycle.ts (1)
  • onPageLoaded (80-86)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: build
  • GitHub Check: build-dummy-app-webpack-test-bundles (3.4, 22, latest)
  • GitHub Check: claude-review
🔇 Additional comments (1)
packages/react-on-rails/tests/pageLifecycle.test.js (1)

71-82: LGTM!

Test correctly validates that no readystatechange listener is added when document.readyState is already 'complete'.

@claude
Copy link

claude bot commented Nov 12, 2025

Code Review

Thank you for this PR! I've reviewed the changes to address the component registration race condition. Here's my detailed feedback:

Overview

This PR changes the page lifecycle initialization from waiting for DOMContentLoaded to waiting for readyState === 'complete', ensuring all subresources are loaded before hydration begins. This is a valid approach to prevent race conditions when the Callback Registry was moved to Pro-only.


🔴 Critical Issues

1. Potential Infinite Recursion in onPageReady (pageLifecycle.ts:72-75)

The current implementation has a recursion issue:

document.addEventListener('readystatechange', function onReadyStateChange() {
  onPageReady(callback);  // Recursively calls itself
  document.removeEventListener('readystatechange', onReadyStateChange);
});

Problem: If readystatechange fires multiple times (e.g., loadinginteractivecomplete), each event could trigger multiple nested onPageReady calls before the listener is removed. The listener is removed AFTER the recursive call, not before.

Recommended fix:

document.addEventListener('readystatechange', function onReadyStateChange() {
  document.removeEventListener('readystatechange', onReadyStateChange);  // Remove first
  onPageReady(callback);  // Then recurse
});

Or better yet, check the state in the listener:

document.addEventListener('readystatechange', function onReadyStateChange() {
  if (document.readyState === 'complete') {
    document.removeEventListener('readystatechange', onReadyStateChange);
    if (!isPageLifecycleInitialized) {
      isPageLifecycleInitialized = true;
      callback();
    }
  }
});

2. Race Condition with Multiple Calls (pageLifecycle.ts:62-77)

The isPageLifecycleInitialized flag is only checked when readyState === 'complete', not when the state is still loading. This means:

  • First call to onPageLoaded: readyState is loading → adds listener
  • Second call to onPageLoaded: readyState is loading → adds ANOTHER listener
  • Both listeners will eventually fire and both will call setupPageNavigationListeners

Impact: Multiple navigation listeners could be registered, causing callbacks to run multiple times.

Recommended fix: Check the flag earlier or use a single shared listener:

let isPageLifecycleInitialized = false;
let isListenerAttached = false;

function onPageReady(callback: () => void) {
  if (typeof window === 'undefined') return;

  if (document.readyState === 'complete') {
    if (isPageLifecycleInitialized) {
      return;
    }
    isPageLifecycleInitialized = true;
    callback();
  } else if (!isListenerAttached) {
    isListenerAttached = true;
    document.addEventListener('readystatechange', function onReadyStateChange() {
      if (document.readyState === 'complete') {
        document.removeEventListener('readystatechange', onReadyStateChange);
        if (!isPageLifecycleInitialized) {
          isPageLifecycleInitialized = true;
          callback();
        }
      }
    });
  }
}

⚠️ High Priority Issues

3. Incomplete Test Coverage (pageLifecycle.test.js)

The removed test at lines 234-254 tested duplicate initialization prevention, which is now even MORE critical given the recursion issue above. This test should be restored and updated, not removed.

Recommendation: Add tests for:

  • Multiple calls to onPageLoaded before complete state
  • Behavior across loadinginteractivecomplete transitions
  • Ensuring setupPageNavigationListeners is only called once

4. Test Name Mismatch (pageLifecycle.test.js:84)

Line 84: Test is named "should wait for readystatechange when document.readyState is 'interactive'" but sets state to 'loading':

it('should wait for readystatechange when document.readyState is "interactive"', () => {
  setReadyState('loading');  // ← Mismatch!

Fix: Either rename the test or add a separate test for interactive state.


💡 Design Considerations

5. Behavioral Change: DOMContentLoaded vs Complete

This is a significant behavioral change:

  • Before: Waited for DOMContentLoaded (DOM ready, but CSS/images/iframes may still be loading)
  • After: Waits for readyState === 'complete' (ALL subresources loaded)

Implications:

  • Pro: Prevents race conditions with async component registration
  • ⚠️ Con: Hydration happens later, potentially causing more FOUC (Flash of Unstyled Content) or delayed interactivity
  • ⚠️ Con: If a page has slow-loading images/iframes, hydration could be significantly delayed

Questions:

  1. Have you tested this on pages with slow-loading resources?
  2. Is there user-facing documentation that needs updating about when hydration occurs?
  3. Could this be made configurable for users who want earlier hydration?

6. Alternative Solution: Load Event

If you want to wait for complete resource loading, the standard load event might be more idiomatic:

if (document.readyState === 'complete') {
  // Already loaded
  callback();
} else {
  window.addEventListener('load', callback, { once: true });
}

This is simpler and avoids the recursion pattern entirely.


📝 Minor Issues

7. TypeScript Type Safety

The onPageReady function accepts callback: () => void but is always called with setupPageNavigationListeners. Consider:

  • Renaming to waitForPageReady for clarity
  • Adding JSDoc explaining the behavior

8. Duplicate Test (pageLifecycle.test.js:84-108)

Lines 84-95 and 97-108 are nearly identical tests. One should be removed or they should test different states (interactive vs loading).


✅ Positive Aspects

  1. Clear problem statement: The PR description clearly explains the race condition issue
  2. Focused scope: Changes are minimal and targeted
  3. Test updates: Tests were updated to match the new behavior
  4. Self-cleaning listener: The listener removes itself after firing

🎯 Recommendations

Immediate (before merge):

  1. Fix the recursion issue in onPageReady (Critical TODO for first version #1)
  2. Fix the multiple listener issue (Critical Make work with turbolinks and better helper #2)
  3. Restore and update the duplicate initialization test (High Add linting and CI scripts #3)
  4. Fix the test name mismatch (High Add integration tests #4)

Follow-up (can be separate PR):

  1. Consider the load event alternative for cleaner code
  2. Add performance monitoring to track hydration delay impact
  3. Document the behavioral change for users
  4. Consider making this configurable via ReactOnRails.configure

📋 Testing Checklist

Please verify:

  • Tests pass locally with yarn run test
  • Manual testing on pages with slow-loading images
  • Manual testing with Turbo/Turbolinks enabled
  • No console errors about missing components
  • Hydration timing feels acceptable to users

Would you like me to help implement any of these fixes? I can provide specific code changes for the critical issues.

Reviewed with: Repository CLAUDE.md guidelines
Focus areas: Code quality, race conditions, test coverage, backward compatibility

@claude
Copy link

claude bot commented Nov 12, 2025

Code Review for PR #1988

Thank you for addressing the component registration race condition! I've reviewed the changes and have both positive feedback and concerns to share.

✅ Strengths

  1. Correct Problem Identification: You've correctly identified that removing the CallbackRegistry in PR PR #4: Split JS Pro Code to Separate Package #1841 broke async hydration support, leading to race conditions where components try to hydrate before webpack bundles finish loading.

  2. Sensible Solution: Waiting for document.readyState === 'complete' (all subresources loaded) is the right approach for the open-source version without CallbackRegistry support.

  3. Test Updates: Tests are properly updated to reflect the new behavior, changing from DOMContentLoaded to readystatechange event.


🚨 Critical Issues

1. Logic Bug: Infinite Recursion Risk ⚠️

The new onPageReady function has a problematic implementation at pageLifecycle.ts:72-76:

document.addEventListener('readystatechange', function onReadyStateChange() {
  onPageReady(callback);  // Recursively calls itself
  document.removeEventListener('readystatechange', onReadyStateChange);
});

Issue: The function calls onPageReady(callback) recursively, which will re-check readyState. If readyState is still not 'complete', it will add another event listener before removing the current one, potentially leading to multiple listeners being registered.

Recommended Fix:

document.addEventListener('readystatechange', function onReadyStateChange() {
  if (document.readyState === 'complete') {
    document.removeEventListener('readystatechange', onReadyStateChange);
    if (!isPageLifecycleInitialized) {
      isPageLifecycleInitialized = true;
      callback();
    }
  }
});

2. Breaking Change: interactive State Behavior 📋

The old implementation triggered on readyState !== 'loading' (includes 'interactive' and 'complete'), but the new implementation only triggers on readyState === 'complete'.

Behavioral difference:

  • Old: Hydration could start when DOM is ready but subresources (images, CSS) still loading ('interactive' state)
  • New: Must wait until ALL subresources finish loading ('complete' state)

Impact: This could noticeably delay hydration on pages with large images or slow-loading assets. While this is intentional to fix the race condition, users should be aware of the performance trade-off.

Test discrepancy: The test pageLifecycle.test.js:84-95 was deleted, but the old behavior description says it should run immediately for 'interactive'. The PR changes this but doesn't document it clearly.

3. Lost Feature: Duplicate Initialization Prevention 🔄

The PR removes the test for "preventing duplicate initialization" (pageLifecycle.test.js:236-255), but the actual duplicate prevention logic still exists in the code.

Questions:

  • Why was this test removed? Does the new implementation still prevent duplicates correctly?
  • The isPageLifecycleInitialized flag is now set inside onPageReady only when readyState === 'complete', but what happens if onPageLoaded is called multiple times while still in 'loading' state? Will multiple readystatechange listeners be added?

🔍 Other Observations

Test Naming Inconsistency

pageLifecycle.test.js:84: Test description says "should wait for readystatechange when document.readyState is 'interactive'" but the test sets readyState to 'loading'. Should this test be renamed or should there be a separate test for 'interactive' state?

Missing Edge Case Test

No test covers what happens when onPageLoaded is called multiple times before readyState === 'complete'. This would help verify duplicate initialization prevention still works.


📊 Performance Considerations

Positive: Using readystatechange with 'complete' check ensures all subresources (JS bundles, images, CSS) are loaded before hydration, eliminating the race condition.

Negative: This may delay time-to-interactive, especially on slow connections. The old behavior allowed hydration to start earlier ('interactive' state), providing faster perceived performance.

Suggestion: Document this trade-off in the CHANGELOG and consider adding a configuration option in the future to let users opt-in to earlier hydration if they handle component registration carefully.


✅ Security & Best Practices

  • No security concerns identified
  • Event listener properly cleaned up with removeEventListener
  • SSR check (typeof window === 'undefined') preserved

📝 Recommendations

  1. Fix the recursive logic bug (critical)
  2. Add test for multiple onPageLoaded calls before 'complete' state
  3. Clarify in PR description: The behavior change from 'interactive''complete' and its performance implications
  4. Consider: Keep the duplicate initialization test or add a comment explaining why it was removed
  5. Update CHANGELOG: Document this as a behavioral change that may affect hydration timing

Overall Assessment

The PR addresses a real problem and takes the right general approach, but the recursive implementation needs refinement to avoid potential bugs. With the suggested fixes, this will be a solid solution for the open-source version's hydration timing.

@claude
Copy link

claude bot commented Nov 12, 2025

Code Review

Overview

This PR changes the page lifecycle initialization from waiting for DOMContentLoaded to waiting for document.readyState === 'complete' (all subresources loaded). This addresses race conditions where React components try to hydrate before their JavaScript bundles are fully loaded.


✅ Strengths

1. Addresses Real Race Condition

The change correctly solves the component registration race condition by ensuring all scripts/resources are loaded before hydration begins. This aligns with the original behavior from PR #1540.

2. Clean Implementation

  • Recursive onPageReady function is elegant and handles the state transition correctly
  • Proper cleanup with removeEventListener to prevent memory leaks
  • Good separation of concerns with the initialization flag

3. Test Updates Are Accurate

The test changes correctly reflect the new behavior:

  • Tests now verify readystatechange instead of DOMContentLoaded
  • Removed duplicate initialization test (which is no longer applicable)

⚠️ Issues & Concerns

1. Critical Bug: Infinite Recursion Risk 🔴

function onPageReady(callback: () => void) {
  if (typeof window === 'undefined') return;

  if (document.readyState === 'complete') {
    if (isPageLifecycleInitialized) {
      return;
    }
    isPageLifecycleInitialized = true;
    callback();
  } else {
    document.addEventListener('readystatechange', function onReadyStateChange() {
      document.removeEventListener('readystatechange', onReadyStateChange);
      onPageReady(callback);  // 🔴 Could recurse if readyState goes: loading → interactive → complete
    });
  }
}

Problem: The readystatechange event fires multiple times as document.readyState transitions:

  • loadinginteractive (DOM parsed)
  • interactivecomplete (all subresources loaded)

When the event fires for interactive, the function recurses and adds another listener, potentially causing unnecessary recursion or timing issues.

Fix: Add a check for the specific state transition:

function onPageReady(callback: () => void) {
  if (typeof window === 'undefined') return;

  if (document.readyState === 'complete') {
    if (isPageLifecycleInitialized) {
      return;
    }
    isPageLifecycleInitialized = true;
    callback();
  } else {
    document.addEventListener('readystatechange', function onReadyStateChange() {
      if (document.readyState === 'complete') {
        document.removeEventListener('readystatechange', onReadyStateChange);
        onPageReady(callback);
      }
    });
  }
}

Alternatively, use the standard load event which fires only once:

function onPageReady(callback: () => void) {
  if (typeof window === 'undefined') return;

  if (document.readyState === 'complete') {
    if (isPageLifecycleInitialized) {
      return;
    }
    isPageLifecycleInitialized = true;
    callback();
  } else {
    window.addEventListener('load', function onLoad() {
      window.removeEventListener('load', onLoad);
      if (isPageLifecycleInitialized) return;
      isPageLifecycleInitialized = true;
      callback();
    });
  }
}

2. Multiple Callback Registration Issue 🟡

Current behavior in pageLifecycle.ts:84 and :92:

export function onPageLoaded(callback: PageLifecycleCallback): void {
  if (currentPageState === 'load') {
    void callback();
  }
  pageLoadedCallbacks.add(callback);
  onPageReady(setupPageNavigationListeners);  // 🟡 Called every time
}

Problem: Every call to onPageLoaded or onPageUnloaded calls onPageReady(setupPageNavigationListeners), which:

  • Before initialization: Adds a new event listener each time
  • After initialization: Returns early, but still executes the check

Impact: If 10 components call onPageLoaded before the page is ready, you'll have 10 event listeners (though they all remove themselves on first fire).

Fix: Only call onPageReady once:

let hasRequestedPageReady = false;

export function onPageLoaded(callback: PageLifecycleCallback): void {
  if (currentPageState === 'load') {
    void callback();
  }
  pageLoadedCallbacks.add(callback);
  
  if (!hasRequestedPageReady) {
    hasRequestedPageReady = true;
    onPageReady(setupPageNavigationListeners);
  }
}

3. Test Coverage Gap 🟡

The removed test "preventing duplicate initialization" was actually testing important behavior. The new implementation should still prevent issues with multiple registrations, but this is no longer explicitly tested.

Recommendation: Add a test that verifies:

  • Multiple onPageLoaded calls before page ready don't cause issues
  • setupPageNavigationListeners is only called once
  • Event listeners are not duplicated

4. Documentation Missing 📝

Missing context:

  • Why complete instead of DOMContentLoaded?
  • What race condition does this solve?
  • Performance implications (delaying hydration)

Recommendation: Add inline comments explaining the change:

// Wait for document.readyState === 'complete' (not just 'interactive') to ensure
// all JavaScript bundles and subresources are loaded before hydration begins.
// This prevents component registration race conditions where ReactOnRails.register()
// may not have been called yet when we try to hydrate components.
// See: https://github.com/shakacode/react_on_rails/pull/1540
function onPageReady(callback: () => void) {

🔍 Additional Observations

5. Test Description Mismatch

it('should wait for readystatechange when document.readyState is "interactive"', () => {
  setReadyState('loading');  // 🤔 Test name says 'interactive' but sets 'loading'

Fix: Update test name to match:

it('should wait for readystatechange when document.readyState is "loading"', () => {

Or add a separate test for interactive state if that's a meaningful distinction.

6. Performance Consideration

Waiting for complete delays hydration until all images, stylesheets, fonts, etc. are loaded. For pages with large assets, this could significantly delay interactivity.

Questions:

  • Have you tested this on pages with large images or slow-loading assets?
  • Is there a way to wait only for JavaScript bundles, not all subresources?
  • Could this be made configurable for apps that don't have the race condition?

7. Removed System Tests 🧪

The PR removes 52 lines of system tests for Turbolinks navigation with immediate hydration.

Question: Are these scenarios now covered elsewhere, or are we accepting reduced test coverage for Pro features?


🎯 Recommendations

High Priority (Must Fix)

  1. Fix the recursion issue with readystatechange handler
  2. Prevent multiple event listener registration in onPageLoaded/onPageUnloaded

Medium Priority (Should Fix)

  1. Add test coverage for multiple registration scenario
  2. Fix test description mismatch
  3. Add inline documentation explaining the race condition

Low Priority (Nice to Have)

  1. Consider performance implications and document them
  2. Add comment explaining why system tests were removed

💭 Questions for Discussion

  1. Have you verified this works correctly when the script is loaded:

    • After interactive but before complete?
    • After complete?
  2. What about Turbo Drive / Turbolinks navigation? Does this affect subsequent page navigations where resources might already be cached?

  3. Could we use a more targeted approach like waiting for specific script tags rather than all subresources?


Summary

The core approach is sound and addresses a real race condition. However, there are implementation bugs that need fixing before merging:

  • ✅ Solves the component registration race condition
  • ✅ Clean, understandable code structure
  • ❌ Has recursion and multiple listener issues
  • ⚠️ May impact performance on slow-loading pages
  • ⚠️ Reduced test coverage for Pro features

Verdict: Needs changes before merging. The bugs are fixable with minor adjustments to the event handling logic.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/react-on-rails/tests/pageLifecycle.test.js (1)

84-108: Duplicate test cases testing identical scenarios.

Both test cases (lines 84-95 and 97-108) set readyState to 'loading' and verify the exact same behavior. After fixing the description mismatch in line 84, these tests become completely identical.

Either remove the duplicate test or modify one to test the "interactive" state:

   it('should wait for readystatechange when document.readyState is "interactive"', () => {
-    setReadyState('loading');
+    setReadyState('interactive');
     const callback = jest.fn();
     const { onPageLoaded } = importPageLifecycle();
 
     onPageLoaded(callback);
 
-    // Should not call callback immediately since readyState is 'loading'
+    // Should not call callback immediately since readyState is 'interactive'
     expect(callback).not.toHaveBeenCalled();
-    // Verify that a DOMContentLoaded listener was added when readyState is 'loading'
+    // Verify that a readystatechange listener was added when readyState is 'interactive'
     expect(addEventListenerSpy).toHaveBeenCalledWith('readystatechange', expect.any(Function));
   });
-
-  it('should wait for readystatechange when document.readyState is "loading"', () => {
-    setReadyState('loading');
-    const callback = jest.fn();
-    const { onPageLoaded } = importPageLifecycle();
-
-    onPageLoaded(callback);
-
-    // Should not call callback immediately since readyState is 'loading'
-    expect(callback).not.toHaveBeenCalled();
-    // Verify that a DOMContentLoaded listener was added when readyState is 'loading'
-    expect(addEventListenerSpy).toHaveBeenCalledWith('readystatechange', expect.any(Function));
-  });
♻️ Duplicate comments (1)
packages/react-on-rails/tests/pageLifecycle.test.js (1)

84-95: Test description doesn't match implementation.

The test description on line 84 says "interactive" but line 85 sets readyState to 'loading'. This creates confusion about what state is actually being tested.

Apply this diff to fix the description:

-  it('should wait for readystatechange when document.readyState is "interactive"', () => {
+  it('should wait for readystatechange when document.readyState is "loading"', () => {
     setReadyState('loading');
🧹 Nitpick comments (1)
packages/react-on-rails/tests/pageLifecycle.test.js (1)

80-80: Update outdated comments referencing DOMContentLoaded.

The comments reference DOMContentLoaded but the implementation now uses the readystatechange event. Update the comments to reflect the current implementation.

Apply this diff:

-    // Should not add DOMContentLoaded listener since readyState is not 'loading'
+    // Should not add readystatechange listener since readyState is 'complete'
     expect(addEventListenerSpy).not.toHaveBeenCalledWith('readystatechange', expect.any(Function));
-    // Verify that a DOMContentLoaded listener was added when readyState is 'loading'
+    // Verify that a readystatechange listener was added when readyState is 'loading'
     expect(addEventListenerSpy).toHaveBeenCalledWith('readystatechange', expect.any(Function));
-    // Verify that a DOMContentLoaded listener was added when readyState is 'loading'
+    // Verify that a readystatechange listener was added when readyState is 'loading'
     expect(addEventListenerSpy).toHaveBeenCalledWith('readystatechange', expect.any(Function));

Also applies to: 93-94, 106-107

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 497c5d1 and de4d34b.

📒 Files selected for processing (3)
  • packages/react-on-rails/src/pageLifecycle.ts (2 hunks)
  • packages/react-on-rails/tests/pageLifecycle.test.js (2 hunks)
  • spec/dummy/spec/system/integration_spec.rb (0 hunks)
💤 Files with no reviewable changes (1)
  • spec/dummy/spec/system/integration_spec.rb
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/react-on-rails/src/pageLifecycle.ts
🧰 Additional context used
🧠 Learnings (2)
📓 Common learnings
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1781
File: node_package/src/ClientSideRenderer.ts:82-95
Timestamp: 2025-09-15T21:24:48.207Z
Learning: In React on Rails, the force_load feature includes both explicit `data-force-load="true"` usage and the ability to hydrate components during the page loading state (`document.readyState === 'loading'`). Both capabilities require a Pro license, so the condition `!railsContext.rorPro && (isComponentForceLoaded || document.readyState === 'loading')` correctly gates both scenarios.
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1875
File: lib/react_on_rails/utils.rb:112-124
Timestamp: 2025-10-23T17:22:01.074Z
Learning: In React on Rails, when Pro is installed but not licensed, the intended behavior is to raise an error on boot. The `react_on_rails_pro?` method validates licenses and should raise errors early (including during path resolution in methods like `server_bundle?`) to enforce licensing requirements rather than failing later with obscure errors.
📚 Learning: 2025-02-13T16:50:47.848Z
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1644
File: node_package/src/clientStartup.ts:18-21
Timestamp: 2025-02-13T16:50:47.848Z
Learning: In the react_on_rails module, the `reactOnRailsPageUnloaded` function in clientStartup.ts is intentionally kept private as it's only used internally as a callback for `onPageUnloaded`.

Applied to files:

  • packages/react-on-rails/tests/pageLifecycle.test.js
🧬 Code graph analysis (1)
packages/react-on-rails/tests/pageLifecycle.test.js (1)
packages/react-on-rails/src/pageLifecycle.ts (1)
  • onPageLoaded (79-85)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: build-dummy-app-webpack-test-bundles (3.2, 20, minimum)
  • GitHub Check: build
  • GitHub Check: claude-review

@Judahmeek
Copy link
Contributor Author

I disagree with Claude & CodeRabbit regarding the recursion. I don't care how many event listeners are created. I only care that they don't actually do anything once isPageLifecycleInitialized is true.

@claude
Copy link

claude bot commented Nov 12, 2025

Code Review

Thanks for working on this fix! I've reviewed the changes and here's my feedback:

✅ Strengths

  1. Correctly addresses the root cause: Since CallbackRegistry was moved to Pro in PR PR #4: Split JS Pro Code to Separate Package #1841, the open-source version no longer supports async/immediate hydration. Reverting to waiting for full page load is the right approach for preventing race conditions.

  2. Improved readiness semantics: Changing from DOMContentLoaded to readystatechange with readyState === 'complete' ensures all subresources (images, stylesheets, scripts) are loaded before hydration, which is more robust.

  3. Test coverage updated appropriately: Tests now reflect the new behavior where interactive state waits rather than proceeding immediately.

🔍 Issues & Concerns

Critical: Potential infinite recursion bug

In pageLifecycle.ts:72-75, the readystatechange event handler recursively calls onPageReady():

document.addEventListener('readystatechange', function onReadyStateChange() {
  document.removeEventListener('readystatechange', onReadyStateChange);
  onPageReady(callback);  // ⚠️ Recursive call
});

Problem: If readyState changes from loadinginteractive (not complete), this will add another event listener and recurse. While the listener removes itself, this creates unnecessary listener churn and could theoretically stack up calls if the state transitions quickly.

Suggested fix: Check if readyState === 'complete' inside the event handler before recursing:

document.addEventListener('readystatechange', function onReadyStateChange() {
  if (document.readyState === 'complete') {
    document.removeEventListener('readystatechange', onReadyStateChange);
    if (!isPageLifecycleInitialized) {
      isPageLifecycleInitialized = true;
      callback();
    }
  }
});

Behavioral regression: duplicate initialization guard removed

The deleted test preventing duplicate initialization (lines 235-251) was testing an important safeguard. The current implementation at line 66-70 only prevents duplicate initialization when readyState === 'complete', but not when it's loading or interactive.

Issue: If onPageLoaded() is called multiple times before the page is ready, each call will add a new readystatechange listener. While they remove themselves, this is wasteful.

Suggested fix: Move the initialization guard outside the readyState check:

let isPageLifecycleInitialized = false;
function onPageReady(callback: () => void) {
  if (typeof window === 'undefined') return;
  if (isPageLifecycleInitialized) return;  // Guard at top level
  
  if (document.readyState === 'complete') {
    isPageLifecycleInitialized = true;
    callback();
  } else {
    document.addEventListener('readystatechange', function onReadyStateChange() {
      if (document.readyState === 'complete') {
        document.removeEventListener('readystatechange', onReadyStateChange);
        if (!isPageLifecycleInitialized) {
          isPageLifecycleInitialized = true;
          callback();
        }
      }
    });
  }
}

Test coverage gap

The removed test for duplicate initialization should be replaced with a test that verifies the new behavior doesn't add multiple listeners when called repeatedly while readyState is not complete.

📊 Performance Considerations

Positive: Waiting for complete instead of DOMContentLoaded may slightly delay hydration, but it eliminates race conditions. This is the right tradeoff for correctness.

Suggestion: Consider adding a comment explaining why complete is used over DOMContentLoaded to help future maintainers understand the design decision.

🔒 Security Concerns

No security issues identified.

📝 Code Quality

  1. Comment clarity: The test comments at lines 91-93 say readyState is 'loading' but the test sets it to 'interactive'. Update for accuracy.

  2. Consistency: Good that the same pattern is used for both onPageLoaded and onPageUnloaded.

✅ Recommendations

  1. Fix the recursion issue in the readystatechange handler
  2. Restore duplicate initialization guard at the function level
  3. Add test coverage for multiple calls before page ready
  4. Update test comments to match actual state values
  5. Add inline comment explaining why complete vs DOMContentLoaded

📋 Style & Conventions

Code follows the project's TypeScript conventions. Remember to run bundle exec rubocop and yarn start format before pushing, as required by CLAUDE.md.

Overall, this PR addresses the right problem with a sensible solution, but needs refinement in the implementation to avoid edge cases and maintain proper guards against duplicate initialization.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (2)
packages/react-on-rails/tests/pageLifecycle.test.js (2)

80-81: Clarify the comment to reflect the actual readyState.

The comment states "since readyState is not 'loading'", which is imprecise. The test sets readyState to 'complete' (line 72), so the comment should clearly reflect that the listener is not added because the page is already in the 'complete' state.

Apply this diff to make the comment more precise:

-    // Should not add readystatechange listener since readyState is not 'loading'
+    // Should not add readystatechange listener since readyState is already 'complete'
     expect(addEventListenerSpy).not.toHaveBeenCalledWith('readystatechange', expect.any(Function));

91-91: Fix incorrect comment about readyState value.

The comment states "since readyState is 'loading'", but line 85 sets the readyState to 'interactive', not 'loading'. This mismatch could confuse future readers.

Apply this diff to correct the comment:

-    // Should not call callback immediately since readyState is 'loading'
+    // Should not call callback immediately since readyState is 'interactive'
     expect(callback).not.toHaveBeenCalled();
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between de4d34b and 23866f1.

📒 Files selected for processing (1)
  • packages/react-on-rails/tests/pageLifecycle.test.js (2 hunks)
🧰 Additional context used
🧠 Learnings (2)
📓 Common learnings
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1781
File: node_package/src/ClientSideRenderer.ts:82-95
Timestamp: 2025-09-15T21:24:48.207Z
Learning: In React on Rails, the force_load feature includes both explicit `data-force-load="true"` usage and the ability to hydrate components during the page loading state (`document.readyState === 'loading'`). Both capabilities require a Pro license, so the condition `!railsContext.rorPro && (isComponentForceLoaded || document.readyState === 'loading')` correctly gates both scenarios.
📚 Learning: 2025-02-13T16:50:26.861Z
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1644
File: node_package/src/turbolinksUtils.ts:34-36
Timestamp: 2025-02-13T16:50:26.861Z
Learning: In React on Rails, when checking for Turbolinks version 5 using `turbolinksVersion5()`, always ensure `Turbolinks` exists first by checking `turbolinksInstalled()` to prevent TypeError when accessing properties.

Applied to files:

  • packages/react-on-rails/tests/pageLifecycle.test.js
🧬 Code graph analysis (1)
packages/react-on-rails/tests/pageLifecycle.test.js (1)
packages/react-on-rails/src/pageLifecycle.ts (1)
  • onPageLoaded (79-85)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (4)
  • GitHub Check: build
  • GitHub Check: build-dummy-app-webpack-test-bundles (3.2, 20, minimum)
  • GitHub Check: build-dummy-app-webpack-test-bundles (3.4, 22, latest)
  • GitHub Check: claude-review
🔇 Additional comments (2)
packages/react-on-rails/tests/pageLifecycle.test.js (2)

97-108: LGTM!

This test correctly verifies the behavior when readyState is 'loading'. The test description, readyState value, and comments are all consistent and accurate.


110-234: LGTM!

The remaining test cases comprehensively cover different navigation libraries (Turbo, Turbolinks 5, Turbolinks 2), multiple callback handling, and server-side rendering scenarios. The test structure is well-organized and the expectations are clear and appropriate.

@claude
Copy link

claude bot commented Nov 12, 2025

Code Review for PR #1988

Thank you for working on this PR to address the component registration race condition. I've reviewed the changes and have the following feedback:


Strengths

  1. Clear Problem Statement: The PR correctly identifies that async hydration support (via CallbackRegistry) moved to Pro, creating a race condition for the open-source version.

  2. Correct Approach: Waiting for document.readyState === 'complete' instead of DOMContentLoaded ensures all subresources (scripts, stylesheets, images) are loaded before hydration, which prevents component registration race conditions.

  3. Test Updates Match Implementation: The test changes correctly reflect the new behavior - waiting for readystatechange instead of DOMContentLoaded.


🔍 Issues & Concerns

1. Critical Logic Error in onPageReady Function

The new implementation has a significant flaw in the recursive event listener pattern:

function onPageReady(callback: () => void) {
  if (typeof window === 'undefined') return;

  if (document.readyState === 'complete') {
    if (isPageLifecycleInitialized) {
      return;  // ❌ PROBLEM: Returns without calling callback
    }
    isPageLifecycleInitialized = true;
    callback();
  } else {
    document.addEventListener('readystatechange', function onReadyStateChange() {
      document.removeEventListener('readystatechange', onReadyStateChange);
      onPageReady(callback);  // Recursive call
    });
  }
}

Problem: When onPageReady is called multiple times (e.g., from both onPageLoaded and onPageUnloaded), subsequent calls after initialization will return early without executing the callback. This breaks the initialization flow.

Expected Behavior: The old code initialized listeners once but allowed callbacks to be registered and executed. The new code prevents proper initialization on subsequent calls.

Suggested Fix: The isPageLifecycleInitialized check should be moved outside the function or handled differently:

function onPageReady(callback: () => void) {
  if (typeof window === 'undefined') return;
  
  if (isPageLifecycleInitialized) {
    callback();  // If already initialized, just run callback
    return;
  }

  if (document.readyState === 'complete') {
    isPageLifecycleInitialized = true;
    callback();
  } else {
    document.addEventListener('readystatechange', function onReadyStateChange() {
      document.removeEventListener('readystatechange', onReadyStateChange);
      onPageReady(callback);
    });
  }
}

2. Behavior Change for interactive State

Old Behavior:

  • readyState !== 'loading' includes both 'interactive' and 'complete'
  • DOMContentLoaded fires during 'interactive' state (DOM ready, subresources may still be loading)
  • This allowed hydration to start as soon as DOM was parsed

New Behavior:

  • Only readyState === 'complete' triggers immediate initialization
  • 'interactive' state now waits for readystatechange event
  • This delays hydration until ALL subresources are loaded

Impact: This is more conservative and safer for race conditions, but may slightly delay time-to-interactive. This tradeoff seems acceptable given the race condition issue, but should be documented.


3. Test Coverage Gap

The removed test (preventing duplicate initialization) was actually testing important behavior. While the old test may not match the new implementation, you should add a new test that verifies:

  • Multiple calls to onPageLoaded / onPageUnloaded don't cause multiple readystatechange listeners
  • The initialization flag properly prevents redundant setup

Suggested test:

it('should handle multiple onPageReady calls without duplicate initialization', () => {
  setReadyState('complete');
  const { onPageLoaded, onPageUnloaded } = importPageLifecycle();
  
  onPageLoaded(jest.fn());
  onPageUnloaded(jest.fn());
  
  // Should only initialize once
  expect(addEventListenerSpy).not.toHaveBeenCalledWith('readystatechange', expect.any(Function));
});

4. Removed Integration Tests

The two removed integration tests (Turbolinks across pages and TurboStream send react component) were testing Pro-specific immediate_hydration functionality. However:

Question: Are these tests moved to the Pro package, or are they no longer needed? The PR description doesn't clarify this.

Recommendation:

  • If these tests are important for Pro functionality, they should be added to the Pro package test suite
  • If immediate hydration is no longer supported even in Pro, this should be documented in the CHANGELOG

5. Missing Event Listener Cleanup

The recursive readystatechange pattern is correctly cleaning up listeners with removeEventListener, but consider edge cases:

  • What happens if the document state changes rapidly?
  • Are there any scenarios where the listener might not be cleaned up?

The current implementation looks correct, but adding a comment explaining the recursive pattern would help future maintainers.


🎯 Performance Considerations

Positive: Waiting for complete state prevents race conditions and ensures consistent behavior.

Potential Concern: Slightly longer time-to-interactive compared to DOMContentLoaded, especially on pages with large images or slow-loading assets.

Recommendation: Consider adding a configuration option to allow advanced users to opt back into DOMContentLoaded behavior if needed (future enhancement).


🔒 Security

No security concerns identified. The changes are limited to page lifecycle management and don't introduce new attack vectors.


📋 Recommendations

Must Fix:

  1. Fix the isPageLifecycleInitialized logic bug in onPageReady function
  2. Add test coverage for duplicate initialization prevention

Should Address:
3. Clarify what happened to the Pro integration tests (moved vs. deleted)
4. Add code comments explaining the recursive readystatechange pattern
5. Update CHANGELOG.md with this user-visible behavior change (components now hydrate after all subresources load)

Nice to Have:
6. Consider documenting the performance tradeoff in code comments
7. Add a test that simulates the race condition this fixes


📝 Style & Convention Notes

Per CLAUDE.md:

  • ✅ Uses TypeScript with proper type annotations
  • ✅ Test descriptions updated to match new behavior
  • ⚠️ CHANGELOG.md should be updated (use /update-changelog command)
  • ⚠️ Ensure bundle exec rubocop passes before pushing

Let me know if you'd like help with any of these fixes!

@claude
Copy link

claude bot commented Nov 12, 2025

Code Review: Only initiate hydration after all subresources loaded

Overview

This PR reverts the hydration behavior to wait for document.readyState === 'complete' instead of 'interactive' or DOMContentLoaded, addressing race conditions where components may attempt to hydrate before their JavaScript bundles are fully loaded.


Code Quality: ✅ Good

Strengths:

  1. Clear intent: The change directly addresses the stated problem of component registration race conditions
  2. Clean implementation: The refactored onPageReady() function is more focused and easier to understand
  3. Proper cleanup: The readystatechange listener is correctly removed after firing

Areas for Improvement:

1. Potential Infinite Recursion Risk ⚠️

packages/react-on-rails/src/pageLifecycle.ts:72-74

The recursive call to onPageReady(callback) inside the readystatechange listener could theoretically cause issues:

document.addEventListener('readystatechange', function onReadyStateChange() {
  document.removeEventListener('readystatechange', onReadyStateChange);
  onPageReady(callback);  // Recursive call
});

Issue: If readyState changes from 'loading''interactive' (not 'complete'), the function will recursively add another listener. While this will eventually resolve when 'complete' is reached, it's not ideal.

Recommendation: Use a loop or check explicitly for 'complete':

function onPageReady(callback: () => void) {
  if (typeof window === 'undefined') return;

  if (document.readyState === 'complete') {
    if (isPageLifecycleInitialized) {
      return;
    }
    isPageLifecycleInitialized = true;
    callback();
  } else {
    const checkReady = () => {
      if (document.readyState === 'complete') {
        document.removeEventListener('readystatechange', checkReady);
        onPageReady(callback);
      }
    };
    document.addEventListener('readystatechange', checkReady);
  }
}

2. Inconsistent Guard Logic

packages/react-on-rails/src/pageLifecycle.ts:67-69

The isPageLifecycleInitialized guard is only checked when readyState === 'complete'. If multiple calls happen while readyState !== 'complete', multiple listeners could be added.

Recommendation: Move the initialization check to the beginning:

function onPageReady(callback: () => void) {
  if (typeof window === 'undefined') return;
  
  if (isPageLifecycleInitialized) {
    return;
  }

  if (document.readyState === 'complete') {
    isPageLifecycleInitialized = true;
    callback();
  } else {
    // ... listener code
  }
}

Test Coverage: ⚠️ Concerns

Issue: Tests No Longer Match Reality

The test changes reveal a semantic mismatch:

Old behavior (DOMContentLoaded):

  • 'loading' → wait for DOMContentLoaded
  • 'interactive' → already past DOMContentLoaded, proceed immediately
  • 'complete' → proceed immediately

New behavior (complete only):

  • 'loading' → wait for 'complete'
  • 'interactive'wait for 'complete' ← This is the key change
  • 'complete' → proceed immediately

However, the test for 'interactive' now expects to wait:

it('should wait for readystatechange when document.readyState is "interactive"', () => {
  setReadyState('interactive');
  const callback = jest.fn();
  const { onPageLoaded } = importPageLifecycle();

  onPageLoaded(callback);

  expect(callback).not.toHaveBeenCalled();
  expect(addEventListenerSpy).toHaveBeenCalledWith('readystatechange', expect.any(Function));
});

Problem: This test will never verify that the callback eventually fires because:

  1. The test sets readyState to 'interactive'
  2. Adds a readystatechange listener
  3. But never simulates the state changing to 'complete'
  4. The callback remains pending indefinitely

Recommendation: Add a follow-up assertion that simulates the state change:

it('should wait for readystatechange when document.readyState is "interactive"', () => {
  setReadyState('interactive');
  const callback = jest.fn();
  const { onPageLoaded } = importPageLifecycle();

  onPageLoaded(callback);

  expect(callback).not.toHaveBeenCalled();
  
  // Capture the listener that was added
  const readystateListener = addEventListenerSpy.mock.calls
    .find(call => call[0] === 'readystatechange')[1];
  
  // Simulate state change to complete
  setReadyState('complete');
  readystateListener();
  
  // Now callback should have fired
  expect(callback).toHaveBeenCalledTimes(1);
});

Removed Test Coverage

The removal of the "preventing duplicate initialization" test is concerning. While it may not have been testing the exact new behavior, the concept of preventing duplicate initialization is still important.

Recommendation: Add a new test that verifies multiple onPageLoaded calls while in non-complete state don't cause issues.


Integration Tests: 🔴 Major Concern

Removed Tests Without Replacement

spec/dummy/spec/system/integration_spec.rb:89-138

Two entire test scenarios were removed:

  1. Turbolinks navigation test - "changes name in message according to input"
  2. TurboStream component test - "force load hello-world component immediately"

Problem: These tests were validating actual user-facing behavior with Pro features. Their removal means:

  • Loss of regression protection for immediate hydration in Turbo/Turbolinks contexts
  • No validation that the new behavior doesn't break Pro features
  • Unclear if these scenarios still work at all

Questions:

  1. Do these features still work with the new implementation?
  2. Were these tests only removed because they depend on immediate_hydration config?
  3. Should they be moved to the Pro package's test suite instead?

Recommendation:

  • If Pro package has its own integration tests that cover this, add a comment referencing them
  • If not, these tests should be adapted to work with the new behavior or moved to Pro
  • At minimum, document why it's safe to remove these tests

Performance Considerations: ⚠️ Trade-off

Change Impact

  • Before: Hydration could start at DOMContentLoaded (when DOM is ready)
  • After: Hydration must wait for load event (all resources loaded)

Pros:
✅ Eliminates race conditions with component registration
✅ Ensures all JavaScript bundles are loaded before hydration

Cons:
⚠️ Slower perceived performance: Users may see a longer delay before interactive content
⚠️ Blocking on unrelated resources: Hydration now waits for ALL resources (images, fonts, etc.), not just scripts

Real-world impact example:

  • Page has large images or slow third-party resources
  • DOM and scripts load quickly
  • Under old behavior: Components hydrate quickly (good UX)
  • Under new behavior: Must wait for images/fonts (degraded UX)

Recommendation: Consider alternative approaches:

  1. Use a module/script load tracking mechanism instead of readyState
  2. Implement a timeout to prevent waiting too long
  3. Document this trade-off in the changelog as a breaking change affecting performance

Security: ✅ No Concerns

No security implications identified. The changes are purely about timing of initialization.


Best Practices Alignment: ⚠️ Partially

Adherence to CLAUDE.md:

  • ✅ Code changes follow TypeScript patterns
  • ✅ Test structure maintained
  • ⚠️ Missing: No CHANGELOG.md entry (should be added per CLAUDE.md)
  • ⚠️ Missing: No update to documentation about behavior change

Recommendations Summary

Critical (Must Address):

  1. Fix infinite recursion potential in onPageReady()
  2. Complete the test for 'interactive' state to verify callback eventually fires
  3. Explain or restore integration tests - document why they were removed

Important (Should Address):

  1. Add CHANGELOG.md entry - this is a user-visible behavior change
  2. Consider performance implications - document the trade-off or explore alternatives
  3. Add test for duplicate initialization prevention

Nice to Have:

  1. Consider adding a configuration option to allow users to choose between 'interactive' and 'complete'
  2. Add JSDoc comments explaining the timing guarantees

Final Verdict: ⚠️ Needs Work Before Merge

While the core fix is sound and addresses a real problem, the implementation has edge cases that should be addressed, and the removal of integration tests without explanation is concerning. The performance trade-off should also be carefully considered and documented.

Suggested next steps:

  1. Address the recursion and guard logic issues
  2. Complete test coverage for the 'interactive' state path
  3. Clarify the integration test removal
  4. Add changelog entry
  5. Consider adding performance metrics/testing

Great work identifying and addressing the race condition! With these refinements, this will be a solid fix. 🚀

@claude
Copy link

claude bot commented Nov 12, 2025

Code Review - PR #1988: Only initiate hydration after all subresources loaded

Overview

This PR addresses a critical race condition by ensuring React component hydration only occurs after the document is fully loaded (readyState === 'complete'), rather than just after DOM is ready (DOMContentLoaded). This prevents component registration race conditions that were occurring in CI.


Strengths

  1. Correct Problem Identification: The change correctly identifies that without the CallbackRegistry (moved to Pro in PR #4: Split JS Pro Code to Separate Package #1841), the core package needs synchronous hydration after all resources load.

  2. Proper Event Usage: Switching from DOMContentLoaded to readyState === 'complete' ensures all subresources (scripts, stylesheets, images) are loaded before hydration begins.

  3. Clean Test Updates: Tests appropriately updated to reflect the new semantics - callbacks wait for complete document readiness.

  4. Removes Invalid Tests: Correctly removes tests for immediate hydration features that are now Pro-only.


⚠️ Critical Issues

1. Logic Bug: Recursive Event Listener Pattern

Location: pageLifecycle.ts:62-68

document.addEventListener('readystatechange', function onReadyStateChange() {
  document.removeEventListener('readystatechange', onReadyStateChange);
  onPageReady(callback);  // ⚠️ Recursively calls itself
});

Problem: This creates a recursive pattern that will re-check document.readyState on EVERY state change (loading → interactive → complete). While it eventually works, it's inefficient and confusing.

Better approach:

document.addEventListener('readystatechange', function onReadyStateChange() {
  if (document.readyState === 'complete') {
    document.removeEventListener('readystatechange', onReadyStateChange);
    if (!isPageLifecycleInitialized) {
      isPageLifecycleInitialized = true;
      callback();
    }
  }
});

Or use the standard window.addEventListener('load', callback, { once: true }) pattern which fires when readyState === 'complete'.

2. Unguarded Global State Mutation

Location: pageLifecycle.ts:66-68

The isPageLifecycleInitialized flag is only set/checked inside the readyState === 'complete' branch. If onPageReady is called multiple times while the document is still loading, multiple event listeners will be registered.

Test case missing: What happens if:

onPageLoaded(callback1); // readyState = 'loading'
onPageLoaded(callback2); // readyState = 'loading' (still)
onPageLoaded(callback3); // readyState = 'loading' (still)

This will register 3 separate readystatechange listeners, each of which will recursively call onPageReady again.


🔍 Code Quality Concerns

3. Inconsistent Naming

The function is named onPageReady but it's actually checking/waiting for document.readyState === 'complete', which is the load event, not just "page ready". Consider:

  • onDocumentComplete
  • onAllResourcesLoaded
  • waitForCompleteLoad

4. Missing Documentation

The change fundamentally alters when hydration occurs (DOMContentLoaded → load event). This is a breaking behavioral change that could affect timing-sensitive code. Should document:

  • Why the change was necessary
  • What the timing difference means for users
  • Potential performance implications (components render later)

🧪 Test Coverage Issues

5. Removed Critical Test

The test "preventing duplicate initialization" was removed, but this functionality is still important. The new code should still prevent duplicate listener registration.

Recommend: Add a test like:

it('should not register multiple readystatechange listeners', () => {
  setReadyState('loading');
  const { onPageLoaded } = importPageLifecycle();
  
  onPageLoaded(jest.fn());
  onPageLoaded(jest.fn());
  onPageLoaded(jest.fn());
  
  // Should only register one listener
  const readyStateListeners = addEventListenerSpy.mock.calls
    .filter(([event]) => event === 'readystatechange');
  expect(readyStateListeners.length).toBe(1);
});

6. No Test for Multiple State Transitions

There's no test verifying the recursive behavior works correctly through multiple readystatechange events.


🚀 Performance Considerations

  1. Delayed Hydration: Components now hydrate later (at load instead of DOMContentLoaded). On pages with many/large images, this could delay interactivity significantly.

  2. Multiple Recursive Calls: The current implementation may call onPageReady twice (once at 'interactive', once at 'complete'), adding unnecessary overhead.


🔒 Security Concerns

No security issues identified.


📋 Recommendations

Must Fix:

  1. Fix the recursive event listener pattern to check readyState within the listener
  2. Add guard to prevent multiple listener registrations
  3. Add test for duplicate registration prevention

Should Fix:

  1. Add JSDoc comments explaining the timing change
  2. Consider using standard window.addEventListener('load') for clarity
  3. Add test for multiple readystatechange transitions
  4. Update CHANGELOG.md if this affects user behavior

Nice to Have:

  1. Rename function to better reflect "complete" vs "ready" semantics
  2. Add performance monitoring to measure hydration delay impact

💡 Alternative Implementation

Consider this cleaner approach:

let isPageLifecycleInitialized = false;

function onPageReady(callback: () => void) {
  if (typeof window === 'undefined') return;
  
  if (isPageLifecycleInitialized) return;
  
  if (document.readyState === 'complete') {
    isPageLifecycleInitialized = true;
    callback();
  } else {
    // Standard pattern: 'load' event fires when readyState becomes 'complete'
    window.addEventListener('load', () => {
      if (!isPageLifecycleInitialized) {
        isPageLifecycleInitialized = true;
        callback();
      }
    }, { once: true });
  }
}

This is more idiomatic, avoids recursion, and uses the built-in once option to prevent duplicates.


Approval Status

Request Changes - The recursive pattern and missing duplicate prevention guard need to be fixed before merge.

The core concept is sound and necessary, but the implementation has bugs that could cause issues in production.

@claude
Copy link

claude bot commented Nov 12, 2025

Code Review for PR #1988: Only initiate hydration after all subresources loaded

Summary

This PR reverts from DOMContentLoaded to waiting for document.readyState === 'complete' (full page load) before initializing hydration. This addresses component registration race conditions in the open-source version now that CallbackRegistry (which enabled async hydration) has been moved to Pro.


Code Quality & Best Practices

✅ Strengths

  1. Clear intent: The refactoring from initializePageEventListeners() to onPageReady(callback) makes the code more semantic and easier to understand.

  2. Self-cleaning listener: The readystatechange listener properly removes itself after firing, preventing memory leaks:

    document.removeEventListener('readystatechange', onReadyStateChange);
  3. Test updates are accurate: Test descriptions and expectations have been updated to reflect the new behavior (waiting for readyState === 'complete' instead of just non-loading states).


Potential Bugs & Issues

🔴 Critical: Logic Error in onPageReady()

The implementation has a significant bug. The current code waits for readyState === 'complete', but the logic is incorrect for the interactive state:

Current behavior:

  • loading → waits for readystatechange
  • interactive → waits for readystatechangeShould wait, but might execute prematurely
  • complete → executes immediately ✅

The problem:
When readyState === 'interactive', the code adds a readystatechange listener, but the very next event will transition to complete. However, the recursive call to onPageReady() inside the event handler will execute the callback when it sees complete. This should work, BUT:

The test at line 84-95 in the old code shows that interactive should trigger immediate execution (no navigation library present), but the new implementation waits. This is actually correct behavior for the PR's intent (waiting for all subresources), but the test update at lines 84-100 in the new test file is wrong.

Issue in test file (lines 84-100):

it('should wait for readystatechange when document.readyState is "interactive"', () => {
  setReadyState('interactive');
  const callback = jest.fn();
  const { onPageLoaded } = importPageLifecycle();

  onPageLoaded(callback);

  // Should not call callback immediately since readyState is 'loading'  // ❌ WRONG COMMENT
  expect(callback).not.toHaveBeenCalled();
  // Verify that a readystatechange listener was added when readyState is 'loading'  // ❌ WRONG COMMENT
  expect(addEventListenerSpy).toHaveBeenCalledWith('readystatechange', expect.any(Function));
});

The comments say "since readyState is 'loading'" but the state is actually 'interactive'. This is a copy-paste error from the 'loading' test case.

🟡 Minor: Recursive Event Handling Pattern

The recursive pattern in onPageReady() is functional but slightly unconventional:

document.addEventListener('readystatechange', function onReadyStateChange() {
  document.removeEventListener('readystatechange', onReadyStateChange);
  onPageReady(callback);  // Recursively calls itself
});

Alternative (more explicit):

function waitForComplete() {
  if (document.readyState === 'complete') {
    if (isPageLifecycleInitialized) return;
    isPageLifecycleInitialized = true;
    callback();
  } else {
    document.addEventListener('readystatechange', waitForComplete, { once: true });
  }
}

Using { once: true } would be cleaner than manual removal, though your current approach works fine.


Performance Considerations

⚠️ Performance Impact (By Design)

Before: Hydration could start at DOMContentLoaded (DOM ready, but stylesheets/images still loading)
After: Hydration waits until readyState === 'complete' (all subresources loaded)

Impact:

  • Slower Time-to-Interactive: Users will wait longer before components become interactive
  • Benefits: Eliminates race conditions where components try to register before webpack bundles fully load

This is the intended tradeoff mentioned in the PR description. However, it would be valuable to:

  1. Document the performance implications in code comments
  2. Consider measuring the actual delay in production scenarios
  3. Evaluate if Pro users with CallbackRegistry have a significant advantage here

Security Concerns

No security issues identified. The changes are limited to event timing logic.


Test Coverage

❌ Test Coverage Gaps

  1. Missing test for duplicate initialization guard:

    • The old test suite had "preventing duplicate initialization" (lines 236-255 in old test file)
    • This test was removed in the PR (lines 232-234 in diff)
    • The guard logic at lines 62-67 in pageLifecycle.ts still exists and should be tested
  2. Test comments are incorrect (see bug section above)

  3. No test for readystatechange event actually firing:

    • Tests verify the listener is added, but don't simulate the event firing and verify callback execution

📋 Recommended Test Additions

// Test that should be kept or re-added:
describe('preventing duplicate initialization', () => {
  it('should not initialize listeners multiple times when document is already complete', () => {
    setReadyState('complete');
    const { onPageLoaded } = importPageLifecycle();
    const callback1 = jest.fn();
    const callback2 = jest.fn();

    onPageLoaded(callback1);
    onPageLoaded(callback2);
    
    // Both callbacks should be called once
    expect(callback1).toHaveBeenCalledTimes(1);
    expect(callback2).toHaveBeenCalledTimes(1);
    
    // But event listeners should only be set up once
    // (This would need internal inspection or mock verification)
  });
});

// Test for actual event firing:
it('should execute callback when readystatechange fires', () => {
  setReadyState('interactive');
  const { onPageLoaded } = importPageLifecycle();
  const callback = jest.fn();

  onPageLoaded(callback);
  expect(callback).not.toHaveBeenCalled();

  // Simulate readystatechange to 'complete'
  setReadyState('complete');
  document.dispatchEvent(new Event('readystatechange'));

  expect(callback).toHaveBeenCalledTimes(1);
});

Integration Impact

🟡 Removed Tests Need Justification

The PR removes 52 lines of integration tests (spec/dummy/spec/system/integration_spec.rb:86-138):

  • shared_context "with pro features and immediate hydration"
  • Two test cases that depend on immediate_hydration

Questions:

  1. Are these tests moved to the Pro package, or are they no longer relevant?
  2. If Pro still supports immediate_hydration, should these tests live in react_on_rails_pro/spec/?
  3. Is there documentation explaining that immediate_hydration is now Pro-only?

Recommendations

🔧 Must Fix

  1. Fix test comments at lines 84-100 in pageLifecycle.test.js (change "loading" to "interactive")
  2. Restore or justify removal of "preventing duplicate initialization" test

💡 Should Consider

  1. Add test that simulates actual readystatechange event firing
  2. Add code comments explaining the performance tradeoff
  3. Document in CHANGELOG or upgrade guide that open-source now waits for full page load
  4. Clarify what happened to the removed integration tests

📚 Nice to Have

  1. Consider using { once: true } option for cleaner event listener code
  2. Add JSDoc comments to onPageReady() explaining when callbacks execute

Conclusion

Overall Assessment: The PR addresses a real issue (race conditions) with a pragmatic solution (wait for full page load). The core logic is sound, but there are test accuracy issues that need fixing before merge.

The approach makes sense given that CallbackRegistry is Pro-only. However, the performance implications should be documented, and the test coverage gaps should be addressed.

Recommendation: Request changes to fix test comments and restore/justify removed test coverage.

@claude
Copy link

claude bot commented Nov 12, 2025

Code Review: Only initiate hydration after all subresources loaded

Overview

This PR addresses a critical race condition issue where component registration could fail due to async hydration being moved to Pro-only. The solution reverts to waiting for readyState === 'complete' instead of DOMContentLoaded, ensuring all subresources are loaded before hydration begins.


✅ Strengths

  1. Problem Identification: Correctly identifies the root cause - the CallbackRegistry (which enabled async hydration) was moved to Pro-only in PR #4: Split JS Pro Code to Separate Package #1841, breaking the open-source async flow.

  2. Conservative Approach: Waiting for readyState === 'complete' is the safest approach for preventing component registration race conditions when scripts load asynchronously.

  3. Test Coverage: Properly updates test descriptions and removes Pro-specific tests that no longer apply to the open-source version.

  4. Clean Implementation: The onPageReady function is well-structured with proper cleanup (removeEventListener).


🔍 Code Quality Observations

pageLifecycle.ts Changes

Good:

  • The onPageReady callback pattern is clean and reusable
  • Properly handles SSR case (typeof window === 'undefined')
  • Correctly removes the event listener after firing to prevent memory leaks
  • Uses readystatechange instead of a one-time load event, which provides better control

Potential Issue - Race Condition:
The recursive onPageReady call on line 74 could potentially miss the complete state:

document.addEventListener('readystatechange', function onReadyStateChange() {
  document.removeEventListener('readystatechange', onReadyStateChange);
  onPageReady(callback); // ⚠️ Recursive call
});

Concern: If readyState changes from loadinginteractivecomplete rapidly, and the listener is removed immediately after the first state change, you might miss the complete state. The standard approach is to check for complete in the event handler itself.

Suggested fix:

document.addEventListener('readystatechange', function onReadyStateChange() {
  if (document.readyState === 'complete') {
    document.removeEventListener('readystatechange', onReadyStateChange);
    if (!isPageLifecycleInitialized) {
      isPageLifecycleInitialized = true;
      callback();
    }
  }
});

Test Changes

Good:

  • Test descriptions accurately reflect the new behavior (readystatechange vs DOMContentLoaded)
  • Removed the duplicate initialization test which no longer applies with the new implementation
  • Updated to expect callback NOT to be called for interactive state (now correctly waits for complete)

Observation:
The test at line 90 now expects interactive state to wait for readystatechange, which is correct given the new implementation. However, this is a behavior change:

  • Before: Both interactive and complete would trigger immediately
  • After: Only complete triggers immediately

This is the intended change and makes sense for preventing race conditions.


Integration Test Cleanup

Good:

  • Removes Pro-specific test contexts that relied on immediate_hydration
  • Reduces test complexity by removing stubbing of Pro features
  • Cleaner separation between open-source and Pro test coverage

Question:
Are these test cases covered elsewhere in the Pro test suite? The removed tests covered:

  1. Turbolinks navigation with immediate hydration
  2. TurboStream component loading with immediate hydration

🐛 Potential Issues

  1. Race Condition (Medium Priority): The readystatechange listener removes itself immediately and recursively calls onPageReady, which could miss the complete state in edge cases. See suggested fix above.

  2. Performance Trade-off (Low Priority): Waiting for readyState === 'complete' means waiting for ALL subresources (images, stylesheets, fonts, etc.). This could delay hydration significantly on slow connections. Consider documenting this trade-off.

  3. Initialization Flag Scope (Low Priority): The isPageLifecycleInitialized flag is checked inside the if (readyState === 'complete') block but not in the else branch. While this works, it means multiple calls to onPageReady could register multiple listeners if called before complete. Consider moving the flag check to the top.


🔒 Security Considerations

No security concerns identified. The changes are purely related to timing of hydration.


⚡ Performance Considerations

  1. Delayed Hydration: This change will delay hydration until ALL subresources are loaded (including images, fonts, etc.), not just the DOM. This is safer but slower.

    • Before: Hydrated at DOMContentLoaded (DOM ready, scripts loaded)
    • After: Hydrated at readyState === 'complete' (everything loaded including images)
  2. User Experience Impact: Users may notice interactive components becoming interactive slightly later, especially on slower connections or pages with many images.

  3. Recommendation: Consider documenting this behavior change in the CHANGELOG as it affects performance characteristics.


📋 Testing Recommendations

  1. Manual Testing: Test on a slow connection (throttled in DevTools) to verify hydration timing doesn't cause noticeable UX issues.

  2. Edge Case Testing: Test scenarios where:

    • Scripts are loaded via defer attribute
    • Scripts are loaded via async attribute
    • Components are registered in multiple script tags
    • Large images are present on the page
  3. CI Verification: Ensure the race condition this fixes (PR #4: Split JS Pro Code to Separate Package #1841) no longer occurs in CI.


🎯 Recommendations

High Priority

  • Fix the potential race condition in the readystatechange listener (see suggested code above)

Medium Priority

  • Add a comment explaining why complete is required (vs DOMContentLoaded)
  • Consider moving the isPageLifecycleInitialized check to prevent multiple listener registrations
  • Update CHANGELOG to document this behavior change and performance impact

Low Priority

  • Add JSDoc comments to onPageReady explaining its purpose
  • Consider adding a test for the interactivecomplete state transition

📝 Summary

Verdict: This is a necessary fix for a critical race condition, and the approach is sound. However, there's a potential edge case in the readystatechange handler that should be addressed before merging.

Impact:

  • ✅ Fixes race condition in component registration
  • ✅ Simplifies open-source codebase by removing Pro-specific behavior
  • ⚠️ Slightly delays hydration (performance trade-off for safety)
  • ⚠️ Potential edge case in event listener logic

The core solution is correct, but the implementation needs a small refinement to handle rapid state transitions properly.


Overall: Approve with minor changes requested (fix the race condition in the event listener).

@claude
Copy link

claude bot commented Nov 12, 2025

Code Review: PR #1988 - Only initiate hydration after all subresources loaded

Thank you @Judahmeek for this PR addressing component registration race conditions!

Summary

This PR changes the page lifecycle to wait for document.readyState === 'complete' instead of !== 'loading', ensuring all subresources (scripts, stylesheets, images) are loaded before hydration begins. This prevents race conditions where components try to hydrate before their registration code has loaded.


Strengths

  1. Addresses Real Problem: Fixes legitimate race condition identified in CI failures where components aren't registered before hydration attempts
  2. Clean Refactor: The onPageReady function is well-structured with proper event listener cleanup
  3. Test Coverage Updated: Tests properly reflect the new readystatechange event behavior
  4. Maintains API Compatibility: Public API (onPageLoaded/onPageUnloaded) remains unchanged

🔴 Critical Issues

1. Logic Bug: Callback Won't Execute (pageLifecycle.ts:62-78)

The current implementation has a serious flaw:

let isPageLifecycleInitialized = false;
function onPageReady(callback: () => void) {
  if (typeof window === 'undefined') return;

  if (document.readyState === 'complete') {
    if (isPageLifecycleInitialized) {
      return;  // ❌ Returns WITHOUT calling callback!
    }
    isPageLifecycleInitialized = true;
    callback();
  } else {
    document.addEventListener('readystatechange', function onReadyStateChange() {
      document.removeEventListener('readystatechange', onReadyStateChange);
      onPageReady(callback);  // ❌ Recursive call when flag is already true
    });
  }
}

Problem: When readystatechange fires and calls onPageReady(callback) recursively:

  • If readyState is now 'complete', it hits the guard at line 65 and returns early
  • The callback never executes

This will break hydration entirely. The tests may not catch this because they don't simulate the actual readystatechange event firing.

Fix:

let isPageLifecycleInitialized = false;
function onPageReady(callback: () => void) {
  if (typeof window === 'undefined') return;

  if (isPageLifecycleInitialized) {
    return;
  }

  if (document.readyState === 'complete') {
    isPageLifecycleInitialized = true;
    callback();
  } else {
    document.addEventListener('readystatechange', function onReadyStateChange() {
      if (document.readyState === 'complete') {
        document.removeEventListener('readystatechange', onReadyStateChange);
        isPageLifecycleInitialized = true;
        callback();
      }
    });
  }
}

2. Test Mismatch (pageLifecycle.test.js:84)

it('should wait for readystatechange when document.readyState is "interactive"', () => {
  setReadyState('interactive');  // ❌ Sets to 'interactive'
  // ...
  expect(callback).not.toHaveBeenCalled();  // ❌ But expects it to wait

This test expects callbacks to wait when readyState === 'interactive', which is correct for the new behavior (only run on 'complete'), but the test description is misleading. The behavior is correct but needs clearer documentation.


⚠️ Moderate Concerns

3. Performance Impact

Waiting for readyState === 'complete' means waiting for all resources to load:

  • Images, fonts, stylesheets, etc.
  • This could significantly delay hydration compared to the previous DOMContentLoaded approach
  • On slow networks with large images, this could be very noticeable

Questions:

  • Have you measured the performance impact in real applications?
  • Could we use a more targeted approach (e.g., wait for specific script bundles rather than ALL resources)?
  • Should this be configurable for apps where the delay is unacceptable?

Suggestion: Consider adding telemetry/logging to measure hydration delay in production.

4. Missing Test Coverage

The updated tests verify that:

  • ✅ Event listeners are added correctly
  • ✅ Callbacks don't run immediately when readyState !== 'complete'

But they DON'T verify:

  • ❌ Callbacks actually execute when readystatechange fires
  • ❌ Event listeners are properly cleaned up
  • ❌ Multiple rapid calls to onPageLoaded work correctly

Recommendation: Add integration test that simulates the actual state transition:

it('should execute callback when readyState becomes complete', () => {
  setReadyState('loading');
  const callback = jest.fn();
  const { onPageLoaded } = importPageLifecycle();
  
  onPageLoaded(callback);
  expect(callback).not.toHaveBeenCalled();
  
  // Simulate state transition
  setReadyState('complete');
  const listener = addEventListenerSpy.mock.calls.find(c => c[0] === 'readystatechange')[1];
  listener();  // Manually fire the listener
  
  expect(callback).toHaveBeenCalledTimes(1);
});

5. Removed System Specs (integration_spec.rb)

The PR removes 52 lines of system specs that tested Pro-mode immediate hydration:

  • "Turbolinks across pages"
  • "TurboStream send react component"

Questions:

  • Are these scenarios now covered elsewhere?
  • Do we lose important integration test coverage?
  • Should there be equivalent tests for the new behavior?

📋 Minor Issues

  1. Documentation: The onPageReady function lacks JSDoc comments explaining:

    • Why it waits for 'complete' instead of 'interactive'
    • The race condition it prevents
    • Performance implications
  2. Changelog: Per CLAUDE.md, this should have a CHANGELOG entry since it affects user-visible behavior (hydration timing)


🔒 Security

No security concerns identified. The changes don't introduce XSS risks or other vulnerabilities.


🎯 Recommendations

Must fix before merge:

  1. ✅ Fix the critical callback execution bug (Issue TODO for first version #1)
  2. ✅ Add test coverage for actual callback execution (Issue Add integration tests #4)

Should address:
3. ⚠️ Measure and document performance impact (Issue #3)
4. ⚠️ Verify removed system specs are covered elsewhere (Issue #5)
5. ⚠️ Add JSDoc documentation (Issue #6)
6. ⚠️ Add CHANGELOG entry (Issue #7)

Consider for follow-up:

  • Make the readyState threshold configurable if performance impact is too high
  • Add performance monitoring to track hydration delays in production
  • Explore more targeted solutions (wait for specific bundles, not all resources)

🏁 Verdict

Status: Needs Changes ⚠️

The approach is sound and addresses a real problem, but the current implementation has a critical bug that will prevent hydration from working. Once Issue #1 is fixed and proper test coverage is added, this will be a solid solution to the race condition.

Please let me know if you'd like help with the fixes or have questions about any of these points!


Review conducted following CLAUDE.md guidelines for React on Rails

@claude
Copy link

claude bot commented Nov 12, 2025

Code Review for PR #1988: Only initiate hydration after all subresources loaded

Thank you for working on this critical race condition fix, @Judahmeek! I've reviewed the changes and have identified several important issues that need to be addressed.


🎯 Summary

This PR changes page lifecycle initialization to wait for document.readyState === 'complete' instead of DOMContentLoaded, ensuring all subresources (scripts, images, stylesheets) are loaded before hydration begins. This addresses component registration race conditions that emerged after the CallbackRegistry was moved to Pro-only.


🔴 Critical Issues

1. Logic Bug: Callback Never Executes Due to Recursive Guard

Location: packages/react-on-rails/src/pageLifecycle.ts:62-76

The current implementation has a critical flaw where the callback will never execute when called recursively:

let isPageLifecycleInitialized = false;
function onPageReady(callback: () => void) {
  if (typeof window === 'undefined') return;

  if (document.readyState === 'complete') {
    if (isPageLifecycleInitialized) {
      return;  // ❌ Returns WITHOUT calling callback!
    }
    isPageLifecycleInitialized = true;
    callback();
  } else {
    document.addEventListener('readystatechange', function onReadyStateChange() {
      document.removeEventListener('readystatechange', onReadyStateChange);
      onPageReady(callback);  // ⚠️ Calls itself recursively
    });
  }
}

Problem Flow:

  1. Document is in 'loading' state
  2. onPageReaded(setupPageNavigationListeners) is called
  3. isPageLifecycleInitialized = true on line 68 (BEFORE checking readyState)
  4. Listener added for readystatechange
  5. Document becomes 'interactive' → listener fires
  6. Listener calls onPageReady(callback) recursively
  7. Now readyState === 'complete' is false AND isPageLifecycleInitialized === true
  8. Callback never executes!

Wait, I'm re-reading the diff. Let me check the actual code in the PR diff again:

Looking at the diff more carefully:

+function onPageReady(callback: () => void) {
+  if (typeof window === 'undefined') return;
+
+  if (document.readyState === 'complete') {
+    if (isPageLifecycleInitialized) {
+      return;
+    }
+    isPageLifecycleInitialized = true;
+    callback();
+  } else {
+    document.addEventListener('readystatechange', function onReadyStateChange() {
+      document.removeEventListener('readystatechange', onReadyStateChange);
+      onPageReady(callback);
+    });
+  }
+}

The issue: The guard isPageLifecycleInitialized is only checked when readyState === 'complete', but it's set to true before waiting completes. The recursive call from the listener will work correctly, but there are still problems:

  • Redundant event listeners: If onPageReady is called multiple times before readyState becomes 'complete' (e.g., from both onPageLoaded and onPageUnloaded), multiple event listeners will be added
  • Event listener not checking state: The listener should verify readyState === 'complete' before calling the callback, not just recursively call onPageReady

Recommended Fix:

let isPageLifecycleInitialized = false;

function onPageReady(callback: () => void) {
  if (typeof window === 'undefined') return;
  
  if (isPageLifecycleInitialized) {
    return;  // Already initialized, don't add more listeners
  }

  if (document.readyState === 'complete') {
    isPageLifecycleInitialized = true;
    callback();
  } else {
    // Only add listener once
    const onReadyStateChange = () => {
      if (document.readyState === 'complete') {
        document.removeEventListener('readystatechange', onReadyStateChange);
        isPageLifecycleInitialized = true;
        callback();
      }
    };
    document.addEventListener('readystatechange', onReadyStateChange);
  }
}

2. Performance Impact: Significant Delay in Hydration

Concern: Waiting for document.readyState === 'complete' means waiting for ALL resources (images, fonts, CSS, async scripts) to finish loading. This can significantly delay interactivity, especially on:

  • Slow networks
  • Pages with large images
  • Pages with external resources (ads, analytics, etc.)

Previous behavior: DOMContentLoaded fires when the DOM is ready, typically much faster

Impact: Users will experience a noticeable delay before React components become interactive

Questions:

  1. Have you measured the performance impact on real applications?
  2. Is this truly necessary for all use cases, or just when async component loading is used?
  3. Could we use a more targeted approach (e.g., wait only for specific component registration scripts)?

Suggestion: Consider making this configurable:

// In ReactOnRails configuration
{
  waitForAllResources: false, // default
  // or
  waitForAllResources: true   // opt-in for apps with component registration issues
}

⚠️ Moderate Issues

3. Test Name Mismatch

Location: packages/react-on-rails/tests/pageLifecycle.test.js:84

it('should wait for readystatechange when document.readyState is "interactive"', () => {
  setReadyState('loading');  // ❌ Test sets 'loading' but name says 'interactive'

The test description doesn't match what it's testing. Should either:

  • Change test name to 'should wait for readystatechange when document.readyState is "loading"'
  • Or actually test the 'interactive' state

4. Incomplete Test Coverage

The updated tests verify that:

  • ✅ Event listeners are added when readyState !== 'complete'
  • ✅ Callbacks don't run immediately when waiting

But they don't verify:

  • ❌ That callbacks actually execute when readyState becomes 'complete'
  • ❌ That event listeners are properly cleaned up
  • ❌ That multiple calls don't create duplicate listeners

Suggested additional test:

it('should execute callback when readyState becomes complete', () => {
  setReadyState('loading');
  const callback = jest.fn();
  const { onPageLoaded } = importPageLifecycle();
  
  onPageLoaded(callback);
  expect(callback).not.toHaveBeenCalled();
  
  // Simulate readyState change
  setReadyState('complete');
  const listener = addEventListenerSpy.mock.calls[0][1];
  listener();
  
  expect(callback).toHaveBeenCalledTimes(1);
});

5. Removed Tests May Hide Issues

Location: packages/react-on-rails/tests/pageLifecycle.test.js:235-253 (deleted)

The removed test "preventing duplicate initialization" was actually useful for verifying that multiple calls to onPageLoaded don't create duplicate listeners. With your changes, this protection is less clear.

Recommendation: Keep or adapt this test to ensure the new implementation still prevents duplicate initialization.


📋 Additional Observations

6. Documentation Needed

This is a significant behavioral change that affects:

  • When React components become interactive
  • Application performance characteristics
  • Debugging timing-related issues

Recommendations:

  1. Add JSDoc comments explaining the behavior:
/**
 * Waits for all page resources to load before executing callback.
 * Uses document.readyState === 'complete' to ensure all subresources
 * (scripts, stylesheets, images) are loaded, preventing race conditions
 * with component registration.
 * 
 * Note: This waits for ALL resources, which may delay hydration compared
 * to DOMContentLoaded. Only use when component registration timing is critical.
 */
function onPageReady(callback: () => void) { ... }
  1. Update CHANGELOG.md if this affects user-facing behavior
  2. Consider adding a migration guide for applications affected by timing changes

7. Removed System Tests

The PR removes the "with pro features and immediate hydration" shared context and two tests that depended on it:

  • "Turbolinks across pages"
  • "TurboStream send react component"

Questions:

  1. Are these tests no longer relevant?
  2. Should they be moved to the Pro package instead?
  3. Is there alternative test coverage for this functionality?

✅ Positive Aspects

  1. Addresses Real Issue: The race condition fix is legitimate and important
  2. Clean Event Listener Management: Proper cleanup with removeEventListener
  3. Tests Updated: Tests reflect the new behavior (though incomplete)
  4. Maintains SSR Safety: Correctly preserves typeof window === 'undefined' check

🎯 Recommendations

Must Fix Before Merge:

  1. ✅ Fix the multiple listener issue by moving the guard check earlier
  2. ✅ Make the event listener check readyState === 'complete' directly instead of recursive calls
  3. ✅ Fix test name mismatch on line 84
  4. ✅ Add test coverage for callback execution on state change

Should Consider:

  1. ⚠️ Measure and document performance impact
  2. ⚠️ Consider making this behavior configurable
  3. ⚠️ Document the behavioral change
  4. ⚠️ Address test coverage gaps
  5. ⚠️ Clarify why system tests were removed

📊 Verdict

Status: Needs Changes 🔧

The approach correctly addresses the race condition, but the implementation has logic issues that could cause problems in production. The performance impact also warrants consideration and documentation.


Review conducted following repository conventions from CLAUDE.md
Linting note: Remember to run bundle exec rubocop before committing fixes!

@claude
Copy link

claude bot commented Nov 12, 2025

Code Review: PR #1988 - Only initiate hydration after all subresources loaded

Overview

This PR addresses a component registration race condition by changing when React hydration begins - from DOMContentLoaded (or readyState !== 'loading') to waiting for document.readyState === 'complete'. This ensures all subresources are loaded before hydration attempts begin.


✅ Strengths

  1. Addresses Real Problem: The race condition fix is valid - the original issue where components try to hydrate before registration completes is a real CI failure.

  2. Clean Refactoring: Replacing initializePageEventListeners with onPageReady simplifies the control flow.

  3. Proper Event Cleanup: Using removeEventListener inside the handler prevents memory leaks.

  4. Updated Test Coverage: Tests correctly reflect the new behavior with readystatechange events.


🚨 Critical Issues

1. Logic Bug: Callback Never Executes on Recursive Call

let isPageLifecycleInitialized = false;
function onPageReady(callback: () => void) {
  if (typeof window === 'undefined') return;

  if (document.readyState === 'complete') {
    if (isPageLifecycleInitialized) {
      return;  // ❌ Returns WITHOUT calling callback!
    }
    isPageLifecycleInitialized = true;
    callback();
  } else {
    document.addEventListener('readystatechange', function onReadyStateChange() {
      document.removeEventListener('readystatechange', onReadyStateChange);
      onPageReady(callback);  // ❌ Recursive call hits the guard above!
    });
  }
}

Problem: When readystatechange fires and calls onPageReady(callback) recursively:

  • isPageLifecycleInitialized is already true (set before adding the listener)
  • The check at line 65 returns early without calling the callback
  • Components never hydrate!

Suggested Fix:

let isPageLifecycleInitialized = false;
function onPageReady(callback: () => void) {
  if (typeof window === 'undefined') return;
  
  if (isPageLifecycleInitialized) {
    return;
  }

  if (document.readyState === 'complete') {
    isPageLifecycleInitialized = true;
    callback();
  } else {
    document.addEventListener('readystatechange', function onReadyStateChange() {
      if (document.readyState === 'complete') {
        document.removeEventListener('readystatechange', onReadyStateChange);
        isPageLifecycleInitialized = true;
        callback();
      }
    });
  }
}

This checks the guard before state checks and directly executes the callback in the listener rather than recursing.

2. Missing Initialization Guard Leads to Duplicate Listeners

The original code had isPageLifecycleInitialized to prevent multiple calls to setupPageNavigationListeners. The current PR checks this flag after readyState, creating a window where:

  1. onPageLoaded is called (readyState = 'loading')
  2. Adds readystatechange listener
  3. onPageUnloaded is called before page completes
  4. Adds another readystatechange listener
  5. Both fire, calling setupPageNavigationListeners twice

This results in duplicate event listeners for Turbo/Turbolinks events and multiple calls to runPageLoadedCallbacks().


⚠️ Design Concerns

3. Performance Impact: Waiting for 'complete' Delays Hydration

readyState === 'complete' waits for ALL resources:

  • Images
  • Fonts
  • Stylesheets
  • Background images in CSS
  • Iframes

For most React apps, DOM-ready (interactive) is sufficient for hydration. Waiting for all images/fonts delays interactivity unnecessarily.

Questions:

  • Have you measured the performance impact on real applications?
  • Does the Pro package have a specific need for this timing that the OSS version doesn't?
  • Could we make this configurable via ReactOnRails.configuration.wait_for_complete?

4. Root Cause: Should We Fix Component Registration Instead?

The PR description states: "async hydration is only supported by ReactOnRails Pro now that the Callback Registry has been moved to ReactOnRails Pro only"

Alternative approaches:

  1. Explicit registration promises: Have components register and track readiness explicitly
  2. Bundle ordering: Use defer scripts or module ordering to guarantee registration before hydration
  3. Registry-based readiness: Create a ComponentRegistry.ready() promise that hydration awaits

These would avoid penalizing all hydration timing for what seems like a build/bundling order issue.

5. Test Coverage: Interactive State No Longer Tested

The test should wait for readystatechange when document.readyState is "interactive" actually sets readyState = 'loading' (line 91), so it doesn't test the interactive state at all.

Since interactive means "DOM ready but subresources loading," this is an important state to test. The original behavior treated interactive and complete the same way; now only complete triggers hydration.


🐛 Minor Issues

6. Removed Tests Without Explanation

The "preventing duplicate initialization" test block was removed (lines 236-254 of the test file). This was testing important behavior - why remove it? The comment says tests were removed that "relied on pro-mode immediate hydration," but duplicate initialization guards should work regardless of pro mode.

7. Removed System Specs May Hide Integration Issues

Removing the Turbolinks and TurboStream specs because they used immediate_hydration means we've lost coverage for:

  • Turbolinks navigation between pages
  • TurboStream dynamic component injection

These should still work without immediate hydration - removing the tests rather than fixing them reduces confidence.


📋 Required Changes Before Merge

  1. ✅ Fix critical logic bug - Callback must execute when readyState becomes 'complete'
  2. ✅ Fix initialization guard - Prevent duplicate listener registration
  3. ✅ Add test for 'interactive' state - Should wait, not execute immediately
  4. ✅ Restore or replace removed test coverage - Duplicate initialization and Turbolinks/TurboStream behavior

🤔 Recommended Discussion Topics

  1. Performance measurement - Quantify the hydration delay this introduces
  2. Alternative solutions - Could we fix registration ordering instead?
  3. Configuration option - Should this be opt-in rather than the default?
  4. Pro vs OSS behavior - Document any differences clearly

🎯 Verdict

Status: Needs Changes 🔴

The approach makes sense for addressing the race condition, but the implementation has a critical bug that prevents callbacks from executing. The performance implications also warrant discussion before changing default behavior for all users.


Review based on CLAUDE.md conventions and React on Rails best practices

@claude
Copy link

claude bot commented Nov 12, 2025

Code Review: PR #1988 - Only initiate hydration after all subresources loaded

Thank you for this PR! I've completed a thorough review. The approach correctly addresses the component registration race condition, but there are some critical implementation issues that need to be fixed before merging.


🎯 Summary

This PR changes page lifecycle initialization from waiting for DOMContentLoaded (or document.readyState !== 'loading') to waiting for document.readyState === 'complete'. This ensures all subresources are loaded before hydration begins, preventing race conditions where components attempt to hydrate before their registration code has loaded.


✅ Strengths

  1. Fixes Legitimate Bug: Addresses real race condition causing CI failures (referenced in PR description)
  2. Clean Refactoring: The onPageReady() wrapper simplifies the initialization logic
  3. Proper Event Cleanup: removeEventListener prevents memory leaks
  4. Updated Tests: Test expectations correctly updated to match new behavior
  5. Removes Dead Code: Appropriately removes pro-only tests that no longer apply

🔴 Critical Issues

1. Recursive Logic Bug (pageLifecycle.ts:62-76)

Problem: The recursive call pattern is fragile and confusing:

function onPageReady(callback: () => void) {
  if (typeof window === 'undefined') return;

  if (document.readyState === 'complete') {
    if (isPageLifecycleInitialized) {
      return;  // ⚠️ Returns WITHOUT calling callback
    }
    isPageLifecycleInitialized = true;
    callback();
  } else {
    document.addEventListener('readystatechange', function onReadyStateChange() {
      document.removeEventListener('readystatechange', onReadyStateChange);
      onPageReady(callback);  // ⚠️ Recursive call AFTER removeEventListener
    });
  }
}

Issues:

  • readystatechange fires multiple times (loading → interactive → complete)
  • First firing at 'interactive': removes listener, then recursively calls onPageReady()
  • But now isPageLifecycleInitialized is still false, so it adds ANOTHER listener
  • This creates unnecessary churn and makes the flow hard to reason about

Impact: While it may work, the logic is error-prone and could break with future changes.

Recommended Fix:

function onPageReady(callback: () => void) {
  if (typeof window === 'undefined') return;

  if (isPageLifecycleInitialized) {
    return;
  }

  if (document.readyState === 'complete') {
    isPageLifecycleInitialized = true;
    callback();
  } else {
    const onReadyStateChange = () => {
      if (document.readyState === 'complete') {
        document.removeEventListener('readystatechange', onReadyStateChange);
        isPageLifecycleInitialized = true;
        callback();
      }
    };
    document.addEventListener('readystatechange', onReadyStateChange);
  }
}

Benefits of this approach:

  • Checks readyState === 'complete' inside the listener (ignores 'interactive')
  • No recursion - clearer control flow
  • Proper initialization guard at the top
  • Cleanup happens after callback execution

2. Test Naming Inconsistency (pageLifecycle.test.js:84)

it('should wait for readystatechange when document.readyState is "interactive"', () => {
  setReadyState('interactive');  // Changed from 'loading' in the diff

Problem: The test was originally for "interactive" but the diff shows it's testing with 'loading'. After reviewing the current file, I see it sets 'interactive', but the test expectations have been changed to expect the callback NOT to be called.

Issue: This is actually incorrect behavior! When readyState is 'interactive':

  • The old code would run the callback immediately (because 'interactive' !== 'loading')
  • The new code waits for 'complete' (which is the intent)

But the test name and expectation mismatch the actual behavior shown in your CURRENT pageLifecycle.ts file (lines 70-71 in my reading), which would run immediately for 'interactive'.

Action Required:

  • Verify this test actually passes with your changes
  • The test expectations in the diff look correct (should wait), but need to ensure implementation matches

3. Test Coverage Gap

The updated tests verify that:

  • ✅ Event listener is added when readyState is loading
  • ✅ Callback doesn't run immediately

But they don't verify:

  • ❌ Callback actually executes when readyState becomes 'complete'
  • ❌ Event listener is properly removed after execution
  • ❌ Multiple rapid calls are handled correctly

Recommended Addition:

it('should execute callback when readyState becomes complete', () => {
  setReadyState('loading');
  const callback = jest.fn();
  const { onPageLoaded } = importPageLifecycle();

  onPageLoaded(callback);
  expect(callback).not.toHaveBeenCalled();

  // Simulate readyState change
  setReadyState('complete');
  const listener = addEventListenerSpy.mock.calls.find(
    call => call[0] === 'readystatechange'
  )[1];
  listener();

  expect(callback).toHaveBeenCalledTimes(1);
});

⚠️ Moderate Concerns

4. Performance Impact

Waiting for readyState === 'complete' means waiting for ALL resources:

  • Images
  • Fonts
  • Stylesheets
  • Background images from CSS
  • Iframes

Comparison:

  • DOMContentLoaded: Fires when HTML is parsed (typically 100-500ms)
  • load (complete): Fires when all resources loaded (can be 2-5+ seconds)

Question: Have you measured the performance impact? For pages with large images or slow CDN resources, this could significantly delay interactivity.

Suggestion: Document this trade-off in the PR description or code comments. Consider if there are alternative solutions like:

  • Ensuring component bundles load with proper defer ordering
  • Using a manifest/registry approach to track bundle readiness
  • Progressive hydration that doesn't wait for ALL resources

5. Removed Tests Without Context

The PR removes 52 lines of integration tests (spec/dummy/spec/system/integration_spec.rb):

  • Turbolinks navigation test
  • TurboStream test

Question: Were these tests only passing because of immediate hydration in Pro mode? Should equivalent tests be added for the new behavior, or are these scenarios no longer supported in the open-source version?


📝 Minor Suggestions

6. Add Code Documentation

Consider adding a JSDoc comment explaining the behavior change:

/**
 * Executes callback after all page resources have loaded.
 * 
 * Waits for document.readyState === 'complete' rather than DOMContentLoaded
 * to ensure all subresources (scripts, stylesheets, images) are loaded before
 * hydration begins. This prevents race conditions where component registration
 * code may not have executed yet.
 * 
 * @param callback - Function to execute when page is ready
 */
function onPageReady(callback: () => void) {

7. Consider Configuration Option

If the performance impact is significant, consider adding a configuration option:

// config/initializers/react_on_rails.rb
ReactOnRails.configure do |config|
  # Wait for all resources (safer, slower) or just DOM (faster, may race)
  config.wait_for_all_resources = true  # default for open source
end

🎯 Verdict

Status: ⚠️ Needs Changes Before Merge

The approach is sound and addresses a real problem, but the implementation has issues:

Must Fix:

  1. ✅ Refactor recursive logic in onPageReady (see recommended fix in issue TODO for first version #1)
  2. ✅ Add test coverage for actual callback execution

Should Address:
3. ⚠️ Document performance trade-offs
4. ⚠️ Clarify why integration tests were removed

Consider:
5. 💡 Add configuration for performance-sensitive apps
6. 💡 Add JSDoc documentation


📋 Testing Checklist

Before merging, please verify:

  • RuboCop passes: bundle exec rubocop
  • All tests pass: rake run_rspec && yarn test
  • Manual testing in dummy app shows components hydrate correctly
  • Manual testing confirms no double-hydration or race conditions
  • Performance impact is acceptable for your use cases

Thanks again for working on this fix! The race condition you're addressing is important. With the suggested changes, this should be a solid solution.

Let me know if you have questions or would like me to clarify any of these points.

@claude
Copy link

claude bot commented Nov 12, 2025

Code Review for PR #1988

Overview

This PR changes the hydration timing from DOMContentLoaded to window.load (document.readyState === 'complete') to prevent race conditions when component registration happens asynchronously. This is a reasonable approach given that async hydration (CallbackRegistry) was moved to Pro.


Critical Issues

CRITICAL BUG: Infinite Recursion Risk

Location: packages/react-on-rails/src/pageLifecycle.ts:62-78

The new onPageReady function has a potential infinite recursion issue. The readystatechange event fires for BOTH 'interactive' AND 'complete' states. When the listener fires at 'interactive', it recursively calls onPageReady(), which will add ANOTHER readystatechange listener, causing unnecessary event listener churn.

Fix: Check the readyState before recursing, or use the traditional window.load event instead.


Test Coverage Issue

Location: packages/react-on-rails/tests/pageLifecycle.test.js:84-108

The test for 'interactive' state has incorrect comments and doesn't verify the correctness of the recursion logic. It should simulate the readystatechange event firing and verify that the callback is eventually called when readyState becomes 'complete'.


Removed Test Coverage

Location: packages/react-on-rails/tests/pageLifecycle.test.js:235-256 (deleted)

The preventing duplicate initialization test was removed. You should verify that duplicate initialization is still prevented with the new implementation. The isPageLifecycleInitialized flag is checked inside the readyState === 'complete' branch, which means multiple calls to onPageLoaded/onPageUnloaded before the page is ready will add multiple readystatechange listeners.


Design Concerns

Performance: Delayed Hydration

Impact: Components will now hydrate LATER - after all subresources (images, stylesheets, fonts, etc.) are loaded instead of after DOM parsing completes.

Trade-offs:

  • PRO: Eliminates race conditions with async component registration
  • PRO: Ensures all subresources are loaded before hydration
  • CON: Users will see non-interactive components for longer
  • CON: Time-to-interactive (TTI) metrics will be worse
  • CON: On slow networks with large images, hydration could be delayed significantly

Suggestion: Consider documenting this trade-off in the changelog/docs. Users migrating from Pro to open-source may notice their apps feel slower.


Semantic Change: DOMContentLoaded to window.load

The PR uses readystatechange checking for 'complete' instead of the more explicit window.load event. Both fire at the same time, but window.load is more explicit about intent.


Code Quality

Good:

  • Function extraction (onPageReady) improves code organization
  • Self-removing event listener prevents memory leaks
  • Proper SSR check

Needs Improvement:

  • Fix the recursion bug
  • Update test to properly validate the interactive state behavior
  • Consider adding back a test for duplicate initialization prevention
  • Add comments explaining why we wait for complete instead of interactive

Integration Test Removal

Two test contexts were removed from spec/dummy/spec/system/integration_spec.rb. This is correct since these tests were specifically for the Pro feature (immediate_hydration) that was moved to the Pro package. Verify these tests exist in react_on_rails_pro/ test suite.


Recommendations

Priority 1 (Must Fix):

  1. Fix the infinite recursion/multiple listener issue in onPageReady
  2. Update test expectations and comments for the interactive state test
  3. Add test to verify that readystatechange to complete flow works correctly

Priority 2 (Should Consider):
4. Document the performance implications of this change (delayed TTI)
5. Consider using window.load for clarity instead of readystatechange
6. Add a comment explaining why we wait for complete vs interactive

Priority 3 (Nice to Have):
7. Verify the removed tests exist in the Pro package
8. Consider documenting the migration path for Pro users


Summary

Concept: Sound approach to fix race conditions
Implementation: Has a critical bug with event listener handling
Tests: Pass but do not fully validate the new behavior
Trade-offs: Should be documented for users

Recommendation: Request changes to fix the recursion issue before merging.

@claude
Copy link

claude bot commented Nov 12, 2025

Code Review: PR #1988 - Only initiate hydration after all subresources loaded

Overview

This PR changes the page lifecycle initialization logic to wait for the complete document state (all subresources loaded) instead of DOMContentLoaded (DOM parsed). This aims to prevent component registration race conditions by ensuring all JavaScript bundles are fully loaded before attempting hydration.


Critical Issues

🔴 1. Significant Behavior Change Without Sufficient Testing

Issue: The PR changes from DOMContentLoaded to readystatechangecomplete, which is a fundamental timing change:

  • Before: Hydration starts when DOM is parsed (DOMContentLoaded)
  • After: Hydration waits until ALL resources (images, stylesheets, fonts, etc.) are loaded (complete)

Impact: This could significantly delay component hydration, especially on pages with large images or slow-loading resources. The delay could be noticeable to users.

Evidence: The test changes show this is intentional (line 97 comment change from "interactive" to "loading"), but there's no performance testing or measurement of the impact.

Recommendation:

  • Add performance metrics/logging to measure hydration delay
  • Consider a timeout fallback if resources take too long
  • Document the performance tradeoff in the PR description

🟡 2. Removed Test Coverage for Duplicate Initialization

Issue: The PR removes the "preventing duplicate initialization" test (lines 236-255 in the old test file) that verified isPageLifecycleInitialized prevents multiple listener registrations.

Problem: The new implementation still uses this flag (pageLifecycle.ts:61-62, 65-68), but now lacks test coverage. The logic changed subtly - the flag is now checked INSIDE the onPageReady callback instead of at the function entry.

Risk: Without tests, future refactors could break this behavior and cause memory leaks from duplicate event listeners.

Recommendation: Add back a test that verifies:

it('should not initialize listeners multiple times when document is complete', () => {
  setReadyState('complete');
  const { onPageLoaded } = importPageLifecycle();
  
  onPageLoaded(jest.fn());
  onPageLoaded(jest.fn());
  
  // Verify setupPageNavigationListeners is only called once
  expect(addEventListenerSpy).toHaveBeenCalledTimes(0); // No nav library
  // But both callbacks should be registered
});

🟡 3. Incorrect Test Description and Expectation (Line 84-95)

Issue: The test at line 84 says "should wait for readystatechange when document.readyState is 'interactive'" but the test actually verifies that callbacks DON'T run immediately when state is 'interactive'.

Problem: According to MDN, interactive means the document is parsed but subresources are still loading. This is similar to DOMContentLoaded timing.

The new code treats 'interactive' same as 'loading' - it waits for 'complete'. This is MORE conservative than before.

Current behavior:

if (document.readyState === 'complete') {  // Only 'complete' runs immediately
  callback();
} else {  // 'loading' AND 'interactive' both wait
  addEventListener('readystatechange', ...);
}

Recommendation: This might be intentional, but:

  1. Document WHY 'interactive' must wait (since DOM is already parsed then)
  2. Consider if 'interactive' could safely trigger hydration (original DOMContentLoaded timing)
  3. If not, add a comment explaining why both loading and interactive must wait for complete

Correctness Issues

🟡 4. Missing removeEventListener Verification in Tests

Issue: The new implementation calls removeEventListener (line 69 of pageLifecycle.ts) to clean up the readystatechange listener, but no tests verify this cleanup happens.

Risk: Memory leaks if the cleanup logic is broken in future changes.

Recommendation: Add test:

it('should remove readystatechange listener after firing', () => {
  setReadyState('loading');
  const { onPageLoaded } = importPageLifecycle();
  
  onPageLoaded(jest.fn());
  
  expect(addEventListenerSpy).toHaveBeenCalledWith('readystatechange', expect.any(Function));
  
  // Simulate readystatechange event
  const listener = addEventListenerSpy.mock.calls[0][1];
  setReadyState('complete');
  listener();
  
  expect(removeEventListenerSpy).toHaveBeenCalledWith('readystatechange', listener);
});

🟡 5. Recursive onPageReady May Not Handle Edge Cases

Issue: The onPageReady function calls itself recursively after adding a readystatechange listener (lines 67-71).

Concern: readystatechange fires multiple times as readyState changes (loading → interactive → complete). The current implementation removes the listener after first fire, but what if state is still 'interactive'?

Flow:

  1. State is 'loading', add listener
  2. State changes to 'interactive', listener fires
  3. onPageReady called again, state is 'interactive' (not 'complete')
  4. Adds ANOTHER listener... infinite recursion?

Wait, looking closer: The listener removes itself BEFORE calling onPageReady, so this should be safe. But it's subtle.

Recommendation: Add a test case that simulates the state transition sequence (loading → interactive → complete) to verify the recursion terminates correctly.


Code Quality Observations

✅ Good: Clean Separation of Concerns

The refactor of initializePageEventListenersonPageReady improves readability. The new function name better describes its purpose.

✅ Good: Consistent Naming

Changed from DOMContentLoaded references to readystatechange in test descriptions (lines 80, 88, 97, 107).

⚠️ Nitpick: Magic Boolean Flag

The isPageLifecycleInitialized flag prevents duplicate initialization, but it's a module-level boolean that persists across the application lifetime. Consider if this could cause issues with:

  • Hot module replacement during development
  • Test isolation (though tests use jest.resetModules())
  • Multiple React on Rails instances on one page (unlikely but possible)

Security Concerns

✅ No security issues identified. The changes don't introduce XSS, injection vulnerabilities, or unsafe DOM manipulation.


Performance Considerations

⚠️ Primary Concern: Delayed Hydration

Before: Hydration starts when DOM is parsed (typically 50-200ms after page load)
After: Hydration waits for ALL resources including images (could be 1-5+ seconds on slow connections)

User Impact:

  • Components remain non-interactive for longer
  • May see visible "lag" before buttons/inputs work
  • Could affect metrics like Time to Interactive (TTI)

Mitigation strategies not implemented:

  • Timeout fallback if resources take too long
  • Progressive hydration (start with critical components)
  • Resource hints (<link rel="preload">) to speed up bundle loading

Testing Coverage

✅ Good Coverage:

  • Tests for all three readyState values
  • Tests for multiple navigation libraries (Turbo, Turbolinks 5, Turbolinks 2)
  • Tests for SSR scenario (no window)

⚠️ Missing Coverage:

  • Duplicate initialization prevention (test removed)
  • removeEventListener cleanup
  • State transition sequence (loading → interactive → complete)
  • Performance impact measurement
  • Timeout fallback behavior (not implemented)

Recommendations

High Priority:

  1. Restore duplicate initialization test with updated assertions
  2. Add test for removeEventListener cleanup
  3. Document performance tradeoff in PR description and code comments
  4. Consider timeout fallback: If resources take >5 seconds, hydrate anyway

Medium Priority:

  1. Add state transition test (loading → interactive → complete)
  2. Clarify why 'interactive' must wait for 'complete' (add comment)
  3. Measure performance impact in real-world scenario

Low Priority:

  1. Consider resource hints to preload critical bundles
  2. Add JSDoc comments to onPageReady explaining the recursion pattern

Suggested Code Improvements

Add timeout fallback:

function onPageReady(callback: () => void, timeoutMs = 5000) {
  if (typeof window === 'undefined') return;

  if (document.readyState === 'complete') {
    if (isPageLifecycleInitialized) return;
    isPageLifecycleInitialized = true;
    callback();
  } else {
    const timeoutId = setTimeout(() => {
      console.warn('Page ready timeout - hydrating anyway');
      onPageReady(callback, 0);
    }, timeoutMs);

    document.addEventListener('readystatechange', function onReadyStateChange() {
      clearTimeout(timeoutId);
      document.removeEventListener('readystatechange', onReadyStateChange);
      onPageReady(callback, 0);
    });
  }
}

Add explanatory comment:

// Wait for 'complete' state (not just 'interactive') to ensure all webpack bundles
// are fully loaded before attempting component registration. This prevents race
// conditions where components are rendered before their registration code executes.
// Tradeoff: Hydration is delayed until ALL page resources (images, fonts, etc.) load.
if (document.readyState === 'complete') {

Summary

Overall Assessment: The PR correctly addresses the race condition issue, but introduces a significant timing change that could impact user experience. The implementation is mostly sound, but needs:

  1. ✅ Better test coverage (restore removed test, add cleanup test)
  2. ⚠️ Performance consideration (timeout fallback, measurement)
  3. 📝 Better documentation (explain the tradeoff)

Recommendation: Request changes - Add missing tests and document performance implications before merging.


Generated by Claude Code - Review completed with thorough analysis of code changes, test coverage, and potential impacts.

@claude
Copy link

claude bot commented Nov 12, 2025

Code Review for PR #1988

Summary

This PR reverts hydration timing to wait for document.readyState === 'complete' instead of DOMContentLoaded, addressing race conditions with component registration when async hydration (CallbackRegistry) is only available in the Pro version.


Positive Aspects ✅

  1. Clear Problem Statement: The PR properly identifies and addresses a real race condition issue that can occur when components register asynchronously
  2. Appropriate Test Updates: Correctly updated test descriptions and expectations to reflect the new readystatechange event behavior
  3. Removes Pro-Specific Tests: Properly removed tests that relied on immediate_hydration feature now only available in Pro
  4. Clean Refactoring: The onPageReady function is well-structured with proper recursion handling

Issues & Concerns ⚠️

1. Critical Logic Bug: Missing Recursive Check

File: packages/react-on-rails/src/pageLifecycle.ts:62-77

The new onPageReady function has a problem - the isPageLifecycleInitialized flag check only happens when readyState === 'complete', but the recursive call can bypass this check:

function onPageReady(callback: () => void) {
  if (typeof window === 'undefined') return;

  if (document.readyState === 'complete') {
    if (isPageLifecycleInitialized) {
      return;  // Only checked here!
    }
    isPageLifecycleInitialized = true;
    callback();
  } else {
    // No guard here - could add multiple event listeners
    document.addEventListener('readystatechange', function onReadyStateChange() {
      document.removeEventListener('readystatechange', onReadyStateChange);
      onPageReady(callback);
    });
  }
}

Problem: If onPageReady is called multiple times while readyState !== 'complete', multiple readystatechange listeners will be registered. When readyState changes, all those listeners will fire, causing duplicate initialization.

Recommendation: Move the isPageLifecycleInitialized check to the top of the function:

function onPageReady(callback: () => void) {
  if (typeof window === 'undefined') return;
  
  if (isPageLifecycleInitialized) {
    return;
  }

  if (document.readyState === 'complete') {
    isPageLifecycleInitialized = true;
    callback();
  } else {
    document.addEventListener('readystatechange', function onReadyStateChange() {
      document.removeEventListener('readystatechange', onReadyStateChange);
      onPageReady(callback);
    });
  }
}

2. Test Coverage Gap

The PR removes the "preventing duplicate initialization" test from pageLifecycle.test.js (lines 236-255 in the old file). However, this scenario is still important to test with the new implementation.

Recommendation: Add back a test that verifies multiple calls to onPageLoaded don't register multiple readystatechange listeners when readyState !== 'complete'.


3. Incorrect Test Expectations

File: packages/react-on-rails/tests/pageLifecycle.test.js:84-95

The updated test for readyState === 'interactive' now expects the callback to NOT be called immediately. However, this changes the previous behavior where interactive state (DOM ready, but resources still loading) would proceed immediately.

Question: Is this intentional? interactive means the DOM is fully parsed and interactive, which was previously sufficient. Now we're waiting for complete (all subresources loaded including images, stylesheets, etc.).

Impact: This could delay hydration significantly on pages with large images or slow-loading external resources, potentially causing a flash of unstyled/non-interactive content.

Recommendation: Consider whether this is the right trade-off. If the goal is just to ensure all JavaScript bundles are loaded, you might want to differentiate between interactive (DOM ready) and complete (all resources loaded).


4. Performance Consideration

Concern: Waiting for readyState === 'complete' means waiting for ALL subresources (images, fonts, stylesheets, etc.) to load. This could significantly delay React hydration, especially on pages with large images or slow CDNs.

Previous behavior: DOMContentLoaded fires when HTML is parsed and DOM is ready
New behavior: complete fires when ALL resources are loaded

Question: Is the component registration race condition specifically related to JavaScript bundles, or all resources? If it's just JS bundles, there might be a lighter-weight solution.

Recommendation: Consider adding a comment explaining why we need complete specifically, and document the performance trade-off in the CHANGELOG entry.


5. CHANGELOG Update Missing

The PR description mentions this is a user-visible change (fixes race conditions that could affect production apps), but I don't see a CHANGELOG.md entry.

Recommendation: Add a CHANGELOG entry under the appropriate section:

#### Bug Fixes
[PR 1988](https://github.com/shakacode/react_on_rails/pull/1988) by [Judahmeek](https://github.com/Judahmeek): Fixed race condition in component hydration by ensuring all subresources are loaded before initializing. Hydration now waits for `readyState === 'complete'` instead of `DOMContentLoaded`.

Testing Recommendations 🧪

  1. Manual test: Load a page with large images and verify React components still hydrate correctly
  2. Performance test: Measure time-to-interactive before/after on a typical page
  3. Edge case: Test on slow connections (throttle network in DevTools)
  4. Verify fix: Confirm the original race condition from the CI failure is resolved

Code Style ✅

  • Follows RuboCop and ESLint conventions
  • TypeScript types are properly maintained
  • Comments are clear where present

Security Assessment ✅

No security concerns identified. Changes are limited to timing of lifecycle events.


Verdict

Changes Requested - The logic bug in onPageReady needs to be fixed before merging. The test coverage gap and performance considerations should also be addressed.

Once the duplicate initialization guard is fixed, this PR will properly address the race condition issue. Consider documenting the performance trade-off and whether complete is truly necessary vs. a lighter-weight alternative.

@claude
Copy link

claude bot commented Nov 12, 2025

Code Review: Only initiate hydration after all subresources loaded

Thank you for this PR! I've reviewed the changes and here's my comprehensive feedback:


📊 Overview

Purpose: Change page lifecycle initialization from DOMContentLoaded to document.readyState === 'complete' to prevent component registration race conditions.

Files Changed:

  • pageLifecycle.ts: Core logic change
  • pageLifecycle.test.js: Test updates
  • integration_spec.rb: Removed Pro-specific tests

✅ Strengths

  1. Addresses Real Issue: Fixes legitimate race condition where components hydrate before registration code loads
  2. Clean Refactor: Replaces initializePageEventListeners with focused onPageReady function
  3. Test Updates: Tests correctly updated to reflect new readystatechange behavior
  4. Proper Cleanup: Event listener is properly removed after execution

⚠️ Critical Issues

1. Recursive Logic Bug (pageLifecycle.ts:61-78)

The current implementation has a critical flaw:

function onPageReady(callback: () => void) {
  if (typeof window === 'undefined') return;

  if (document.readyState === 'complete') {
    if (isPageLifecycleInitialized) {
      return;  // ❌ Returns early without calling callback
    }
    isPageLifecycleInitialized = true;
    callback();
  } else {
    document.addEventListener('readystatechange', function onReadyStateChange() {
      document.removeEventListener('readystatechange', onReadyStateChange);
      onPageReady(callback);  // ❌ Recursive call AFTER removal
    });
  }
}

Problems:

  1. Listener removal happens before callback: The removeEventListener on line 74 executes BEFORE the recursive onPageReady call on line 75. This means if readyState transitions loading → interactive → complete, the listener fires twice but gets removed after the first fire.

  2. Initialization guard position: The guard if (isPageLifecycleInitialized) at line 67 is inside the if (document.readyState === 'complete') block, meaning it won't prevent duplicate initialization if called multiple times while loading.

Recommended Fix:

let isPageLifecycleInitialized = false;

function onPageReady(callback: () => void) {
  if (typeof window === 'undefined') return;
  
  // Guard at top level to prevent any duplicate initialization
  if (isPageLifecycleInitialized) {
    return;
  }

  if (document.readyState === 'complete') {
    isPageLifecycleInitialized = true;
    callback();
  } else {
    const onReadyStateChange = () => {
      if (document.readyState === 'complete') {
        document.removeEventListener('readystatechange', onReadyStateChange);
        isPageLifecycleInitialized = true;
        callback();
      }
    };
    document.addEventListener('readystatechange', onReadyStateChange);
  }
}

This fix:

  • Checks readyState inside the listener (no recursion)
  • Removes listener only when actually complete
  • Guards against duplicate initialization at the top level
  • Sets initialization flag after successful setup

2. Test Naming Bug (pageLifecycle.test.js:84)

it('should wait for readystatechange when document.readyState is "interactive"', () => {
  setReadyState('interactive');  // Sets to 'interactive'
  // But now expects waiting behavior, which contradicts the old behavior

The test name and implementation changed. In the OLD code, interactive state would trigger immediate initialization (line 70: if (document.readyState !== 'loading')). The NEW code waits only for complete.

Recommendation: The new behavior is intentional per the PR description. The test correctly validates that interactive state now WAITS for complete, but consider adding a comment explaining this behavior change is intentional.


3. Missing Test Coverage for Actual Firing

The tests verify that:

  • ✅ Event listeners are added
  • ✅ Callbacks don't run immediately when loading

But they DON'T verify:

  • ❌ Callbacks actually execute when readyState becomes complete
  • ❌ Event listeners are properly cleaned up after firing

Suggested Additional Test:

it('should call callback when readyState changes to complete', () => {
  setReadyState('loading');
  const callback = jest.fn();
  const { onPageLoaded } = importPageLifecycle();

  onPageLoaded(callback);
  expect(callback).not.toHaveBeenCalled();

  // Simulate readyState change to 'complete'
  setReadyState('complete');
  document.dispatchEvent(new Event('readystatechange'));

  expect(callback).toHaveBeenCalledTimes(1);
  // Verify listener was removed
  expect(removeEventListenerSpy).toHaveBeenCalledWith('readystatechange', expect.any(Function));
});

🤔 Design Questions

4. Performance Impact

Waiting for readyState === 'complete' means waiting for ALL resources (images, fonts, stylesheets, etc.) to load, not just the DOM and scripts. This could significantly delay hydration compared to DOMContentLoaded.

Questions:

  • Have you measured the performance impact on the example apps?
  • For image-heavy pages, this could delay interactivity by seconds
  • Could we use a more targeted approach that only waits for critical script bundles?

Alternative Consideration:
Since the root cause is component registration timing, could we instead:

  1. Ensure component bundles load with proper ordering (defer, module)?
  2. Have the registration code signal when it's ready?
  3. Only use complete as a fallback for Pro features that need it?

5. Removed Pro Tests Implications

The PR removes the with pro features and immediate hydration test context and two test cases.

Questions:

  • Are these scenarios now impossible without Pro's CallbackRegistry?
  • Should equivalent tests exist in the Pro package?
  • Does this change affect any documented Pro features?

📝 Minor Issues

6. Removed Duplicate Prevention Test

The preventing duplicate initialization test block was removed (pageLifecycle.test.js:236-255). This test was valuable for ensuring the guard works correctly.

Recommendation: Add back a version of this test that works with the new implementation.


🎯 Recommendations Summary

Must Fix Before Merge:

  1. ✅ Fix the recursive logic bug in onPageReady
  2. ✅ Add test coverage for callback execution on readystatechange
  3. ✅ Clarify the test naming for interactive state behavior

Should Consider:
4. 📊 Measure performance impact on example apps
5. 📚 Document the behavior change and its implications for Pro users
6. 🧪 Add back duplicate initialization prevention test

Nice to Have:
7. Add JSDoc comments explaining the complete vs DOMContentLoaded choice
8. Consider a hybrid approach if performance impact is significant


📋 Checklist

Before merging, please ensure:

  • Logic bug in onPageReady is fixed
  • Tests verify callback execution on state change
  • Performance impact measured on dummy app
  • CHANGELOG updated per /update-changelog command
  • bundle exec rubocop passes with zero offenses
  • All test suites pass

Overall Assessment: The PR addresses a real issue with the right general approach, but the implementation has a critical logic bug that needs fixing before merge. Once the recursive pattern is corrected and tests are enhanced, this will be a solid improvement.

Let me know if you'd like me to provide a working implementation or help with testing! 🚀

@Judahmeek
Copy link
Contributor Author

Closed as shouldn't be necessary if all deferred javascript runs before the DOMContentLoaded event, ass specifications insist.

@Judahmeek Judahmeek closed this Nov 12, 2025
@Judahmeek Judahmeek deleted the judahmeek/debug-image-example branch November 20, 2025 00:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants